Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67a89d1794 | ||
|
|
4ddffbbeb1 | ||
|
|
6e59df22e5 |
No files matched your search
@@ -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.
|
||||
- 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.
|
||||
- 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`.
|
||||
|
||||
@@ -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.
|
||||
- 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, …).
|
||||
- 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`.
|
||||
@@ -4,6 +4,103 @@ Alle Änderungen an Grotti. Format nach [Keep a Changelog](https://keepachangelo
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [3.3.0] – 2026-10-18
|
||||
|
||||
### Neu
|
||||
|
||||
- **Projekte** `/projekte`: Arbeitsmappe mit Ordner unter dem Slice-Pfad (`projects.folder`); G-code-Zuordnung per Sync; Upload weiterer Dateien (STEP/STL/PDF/…); Nav unter Verwalten.
|
||||
- **Dateien:** Uploads in Slice-Ordner verschieben (werden Watch); Löschen für Upload und Watch (nur unter Slice/`eingang`); kompaktere Bibliothek-Filter.
|
||||
- **Material:** Filter Sorte/Farbe; leere/archivierte Spulen ausgeblendet (Filter **Archiv**); Buttons **Leer**, **Archivieren**, **Wiederherstellen** (`archived_at`).
|
||||
- **Wand:** kleines Kamera-Snapshot-Thumb auf jeder Karte (nie Stream); Menüpunkt Kameras entfernt, `/kameras` → Wand.
|
||||
|
||||
### Geändert
|
||||
|
||||
- **Technik-Cockpit:** kompakte Fact-Chips; Steuerung in Gruppen Heizung / Druck / ACE-Plätze.
|
||||
- **Protokoll:** kurze Dateinamen, festere Spaltenbreiten.
|
||||
- **Statistik:** kompakte Filter inkl. Farbe, Auto-Laden, KPI Primär/Sekundär.
|
||||
- **Orca-Login:** Session 30 Tage (überlebt App-Fenster schließen); kein zweites Chrome mit demselben Profil (sonst erneutes Anmelden).
|
||||
- **Druckbericht:** Kosten-Torte und Tabelle inkl. Filament (nicht nur Strom); Summe = Material + Energie.
|
||||
- **Dateien:** Starten / Einreihen / Ablauf mit einem Klick (kein Zwei-Klick mehr). API weiter mit `confirm: true`.
|
||||
- **Senden:** Zwei Start-Modi statt drei — **Dieser Drucker** (frei → sofort, sonst Warteschlange) und **Nächster passender** (Grotti wählt nach Sorte).
|
||||
- **Material-Routing:** Match auf Katalog-Sorte aus Orca-`filament_settings_id` (PLA Meta ≠ Matte ≠ Silk); falsche Variante wartet mit Grund. Farbe bleibt soft. Bei Auto-Wahl baut Grotti die ACE-Map aus den Spulen am Claim-Drucker. Bestehende Dateien: **Dateien neu lesen**.
|
||||
- **Wand:** Bei Filament-Pause Hinweis, die neue Rolle unter Material dem Drucker zuzuweisen — sonst zählt der Verbrauch weiter zur alten Spule.
|
||||
- Anleitung Material/ACE: Rollenwechsel während Pause klargestellt.
|
||||
|
||||
### Behoben
|
||||
|
||||
- **Orca/Anycubic öffnet zwei Browserfenster:** Nachbearbeitung läuft oft 2× — Debounce (~25 s) öffnet nur noch eines. Deep-Link lädt fehlende `paint_index` nach (ACE zeigt sonst fälschlich T0/T1).
|
||||
- **Dateien-Tab hängt nach Orca:** 3D-Pfad erst nach ACE/Senden (Idle); Debounce ignoriert Anycubic-Zeitstempel im Dateinamen; erneuter Export aktualisiert den offenen Tab statt ein zweites Fenster.
|
||||
- **Orca/Anycubic → Desktop-Fenster:** `orca_senden` öffnet Grotti als Chrome/Edge-App (`browser: app`). Anycubic-Dialog nur vermeiden durch **G-code exportieren**, nicht Remote Print / Aufgabe starten.
|
||||
- Anleitung Orca: Grotti unter **Gerät** per Device-UI-URL (`http://127.0.0.1:8770/`); Upload-zum-Host weiter meiden.
|
||||
- **ACE-Map mit Lücken-T (Bambu/Anycubic):** UI behält Slicer-`Tn` (z. B. T1+T10). Beim Senden verdichtet Grotti auf T0…n und schreibt passende `.acm` — sonst gklib **10011700 invalid filament id** bzw. Hang bei Map-Mismatch. Senden prüft Map gegen T-Befehle. Bestehende Dateien: **Dateien neu lesen**.
|
||||
- **10011700 trotz dichter UI-Map:** Verdichtung hängt am G-code (`T1`/`T10`), nicht daran, ob die Map schon 0/1 zeigt — sonst blieb T10 auf dem Drucker. Grotti neu starten, Clip erneut senden.
|
||||
- **Abräumen ließ Lauf offen:** Urteil und Platte waren quittiert, der Live-Lauf blieb `in_progress` (Moonraker-History oft ohne Endzeit). Abräumen und Watcher (`printing`→`complete`) schließen den Lauf jetzt als `completed` inkl. Materialabzug.
|
||||
- **Rollenwechsel während Pause:** Einlegen hängt Abschnitte/Ereignisse auch an offene History-Jobs (nicht nur `live:`). Send-Spule und Lauf-Spule werden mitgezogen; Abzug bevorzugt die Rolle am Drucker statt eines veralteten Send-Hints.
|
||||
|
||||
## [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
|
||||
|
||||
### Neu
|
||||
|
||||
- **Bedarf in der UI:** Sidebar-Warnung, Karte unter Material, Chip in der Warteschlange.
|
||||
- **Start-Zeitfenster** je Drucker in den Einstellungen (Queue nur im Fenster).
|
||||
- **HT/NT-Tarif** in den Energie-Einstellungen.
|
||||
- **ntfy-Test** wie bei Telegram.
|
||||
- **Helles Theme** (Dunkel / Hell / System) pro Browser.
|
||||
- Statistik: Balkendiagramme Druckzeit/Verbrauch, Empty-State ohne Daten.
|
||||
- Wartung: KPIs, Empty-State, Reset mit Bestätigung (`armButton`).
|
||||
|
||||
### Geändert
|
||||
|
||||
- Cockpit und Warteschlange nutzen SSE/`createPoller` statt hartem 5‑s-Intervall.
|
||||
- Shared-`config.token` wird nicht mehr als Auth akzeptiert — nur noch Session und `grt_…`-Tokens.
|
||||
- Legacy-Alias `GET /api/drucker` entfernt (nur `/api/v1/drucker`).
|
||||
|
||||
### Behoben
|
||||
|
||||
- SSE unter waitress: Hop-by-Hop-Header `Connection` entfernt (sonst Fehler auf `/api/v1/stream`).
|
||||
- Bedarf: Material/Farbe aus `files.filaments` statt nicht existierender Spalten.
|
||||
- Chrome: Service-Worker lieferte bei Fehlern die Startseite statt der angeklickten Route; „Mehr“-Dialog blockierte die Navigation; SSE und Poller liefen parallel und stauten Verbindungen.
|
||||
|
||||
## [3.0.0] – 2026-10-18
|
||||
|
||||
Major: neue Tabellen (Aufträge, Wartung), Projekte/Ideen → Aufträge, Navigation umgebaut, Dev-Server → waitress, Schema über Migrationen.
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"secret_key": "",
|
||||
"fqdn": "",
|
||||
"auto_queue": true,
|
||||
"auto_retry": true,
|
||||
"oauth": {
|
||||
"enabled": false,
|
||||
"issuer": "",
|
||||
@@ -23,7 +24,13 @@
|
||||
"dryer_watts": 0,
|
||||
"dry_hours": 0,
|
||||
"waste_percent": 0,
|
||||
"utilization": 50
|
||||
"utilization": 50,
|
||||
"tariff": {
|
||||
"ht_euro": 0.35,
|
||||
"nt_euro": 0.22,
|
||||
"nt_from": "22:00",
|
||||
"nt_to": "06:00"
|
||||
}
|
||||
},
|
||||
"telegram": {
|
||||
"enabled": false,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 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.
|
||||
|
||||

|
||||
|
||||
@@ -12,27 +12,41 @@ Stand **17.10.2026**. Mehrfarb-Drucke brauchen vor dem Start eine Zuordnung Date
|
||||
|
||||
Es gibt **kein freies Spulen-Dropdown** beim Start: Grotti nimmt die Rolle(n), die am Ziel-Drucker liegen. Material-Mismatch oder zu wenig Rest → Warnung vor dem Start.
|
||||
|
||||
### Rollenwechsel während Pause
|
||||
|
||||
Während Druck oder Pause die neue Spule unter Material dem Drucker zuweisen und speichern. Grotti schließt den Abschnitt der alten Rolle und öffnet einen neuen — im Druckbericht erscheinen dann zwei Spulenzeilen und Ereignisse. Nur Filament am Gerät laden ohne Zuweisung: Abzug und Protokoll bleiben bei der alten Rolle.
|
||||
|
||||
Wenn Moonraker den Druck nach Filament-Pause als neuen History-Job führt, übernimmt Grotti die neu zugewiesene Rolle für den fortgesetzten Teil (Send- und Lauf-Spule werden beim Einlegen aktualisiert).
|
||||
|
||||
Etiketten mit QR: [Material und mehr](Anleitung-Material.md).
|
||||
|
||||
## ACE-Map beim Senden
|
||||
|
||||
Unter **Dateien → Optionen → ACE-Zuordnung**:
|
||||
|
||||
- Jede Farbe aus der Datei (`paint_index`) einem **ACE-Platz** zuordnen
|
||||
- Liegt unter Material eine Spule auf dem Platz, zeigt die Liste Name, Farbe und Restgramm mit
|
||||
- Jede Farbe aus der Datei (`Tn` / Slicer-`paint_index`) einem **ACE-Platz** zuordnen
|
||||
- Bei Bambu-/Anycubic-Dateien sind die Nummern oft **nicht 0–3** (z. B. T1 und T10 bei 16 Slots) — Grotti zeigt `T…`, verdichtet beim Senden aber auf T0…n (sonst Fehler **invalid filament id**)
|
||||
- Liegt unter Material eine Spule auf dem Platz, zeigt die Liste Name, Farbe und Restgramm mit — Vorauswahl bevorzugt die **Katalog-Sorte** aus dem Orca-Profil (Meta/Matte …)
|
||||
- Ohne passende Map schreibt der Drucker oft **„cannot parse the file“** und startet nicht
|
||||
- Grotti legt die Sidecar `*.gcode.acm` an und schickt sie mit
|
||||
|
||||
Bei **Nächster passender** (kein Drucker gewählt) baut Grotti die Map erst beim Claim aus den Spulen am gewählten Drucker. Passt die Sorte nicht → Wartegrund in der Queue.
|
||||
|
||||
Externe Spule / keine ACE-Slots: keine `.acm` nötig.
|
||||
|
||||
Nach einem Grotti-Update mit ACE-Farben: unter Dateien **neu lesen**, damit alte Einträge die echten `Tn` bekommen.
|
||||
|
||||
## Typische Fehler
|
||||
|
||||
| Symptom | Check |
|
||||
| --- | --- |
|
||||
| „cannot parse the file“ | ACE-Zuordnung vollständig? Farben richtig? |
|
||||
| Material-Warnung | Eingelegte Sorte ≠ Datei — Spule wechseln oder Slice prüfen |
|
||||
| „T… fehlt in der Map“ | Datei neu lesen, Zuordnung erneut setzen (Slicer-T ≠ ACE-Platz) |
|
||||
| **invalid filament id** (10011700) | Alte Grotti-Version ohne T-Verdichtung, oder Map nicht neu gesetzt. Grotti neu starten, Datei neu lesen, nochmal senden. |
|
||||
| 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 |
|
||||
| ACE blinkt nicht / kein Wechsel | Spule und Platz prüfen, ggf. Druck neu starten |
|
||||
| Drucker hängt kurz nach Multi-Color-Start | Oft falsche Map (T10 gemappt als Platz 0). Neu lesen + Zuordnung. |
|
||||
|
||||
## Weiter
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ Im Auftrag **Lieferschein** öffnen: Positionen, Chargen `D-…`, druckbar. Dana
|
||||
|
||||
## Weiter
|
||||
|
||||
- [Projekte](Anleitung-Projekte.md) (Arbeitsmappe / Konstruktion — nicht dasselbe wie Auftrag)
|
||||
- [Warteschlange](Anleitung-Warteschlange.md)
|
||||
- [Material](Anleitung-Material.md)
|
||||
- [Protokoll / Bericht](Anleitung-Oberflaeche.md)
|
||||
@@ -1,31 +1,33 @@
|
||||
# Dateien senden
|
||||
|
||||
Stand **17.10.2026**. Seite `/dateien` — Bibliothek links, Senden rechts.
|
||||
Stand **18.10.2026**. Seite `/dateien` — Bibliothek links, Senden rechts.
|
||||
|
||||

|
||||
|
||||
## Bibliothek
|
||||
|
||||
- Suche nach Name, Material und Projekt
|
||||
- Kompakte Filterleiste: Suche, Maschine, Projekt, Material, Ordner, Sortierung
|
||||
- Filter: Maschine, Projekt, Material, Ordner (Unterordner unter dem Slice-Ordner; Uploads als „Upload“)
|
||||
- Sortierung: Neueste, Name, Material, Ordner, Druckzeit, Gewicht — bei Material/Ordner mit Gruppenköpfen
|
||||
- Auswahl der Filter bleibt im Browser merken
|
||||
- Vorschaubild, Material, Zeit, Gewicht, Schichthöhe
|
||||
- Info-Popup mit Slice-Metadaten (Kopf und Footer)
|
||||
- **Hochladen** per Knopf oder Drag & Drop
|
||||
- Mit gesetztem Slice-Ordner ([Einstellungen](Anleitung-Einstellungen.md)): **Ordner anlegen**, Watch-Dateien per Auswahl **verschieben** (Datei bleibt in der Bibliothek dieselbe, inkl. Projekt)
|
||||
- Mit gesetztem Slice-Ordner ([Einstellungen](Anleitung-Einstellungen.md)): **Ordner anlegen**; Watch- **und** Upload-Dateien per Auswahl **verschieben** (Upload wird zur Watch-Datei im Slice-Ordner)
|
||||
- **Löschen** (Papierkorb, Zwei-Klick) für jede Bibliotheksdatei — entfernt Eintrag und Datei auf der Platte (nur unter Slice-Ordner oder `eingang`)
|
||||
- **Ordner neu lesen**, wenn unter Einstellungen ein Slice-Ordner gesetzt ist
|
||||
|
||||
Ohne Slice-Ordner geht nur manueller Upload — das reicht für den Alltag. Uploads liegen flach unter „Upload“ und lassen sich in der Bibliothek nicht in Slice-Ordner schieben.
|
||||
Ohne Slice-Ordner geht nur manueller Upload; Verschieben in Ordner braucht den Slice-Pfad.
|
||||
|
||||
Projekte mit eigenem Ordner: [Projekte](Anleitung-Projekte.md).
|
||||
|
||||
## Senden: drei Schritte
|
||||
|
||||
1. **Datei** wählen (Klick in der Liste).
|
||||
2. **Drucker** wählen — Kacheln zeigen Live-Zustand und eingelegte Rolle. Passt die Maschine nicht, ist die Kachel blass.
|
||||
3. **Wann:**
|
||||
- **Jetzt** — sofort senden und starten (Zwei-Klick)
|
||||
- **Wenn frei** — in die Warteschlange für diesen Drucker
|
||||
- **Nächster freier** — erster passender freier Drucker
|
||||
3. **Start:**
|
||||
- **Dieser Drucker** — frei und Platte klar → sofort starten; sonst Warteschlange für diesen Drucker
|
||||
- **Nächster passender** — Grotti wählt den nächsten freien Drucker mit passender Sorte (z. B. Sunlu PLA Meta, nicht Matte)
|
||||
|
||||
Auswahl merkt sich der Browser (`localStorage`).
|
||||
|
||||
@@ -44,7 +46,7 @@ Große Dateien brauchen oft **eine Minute**. In der Sidebar unter **Senden** sie
|
||||
## Nach dem Start
|
||||
|
||||
- Fortschritt auf der [Wand](Anleitung-Wand.md) und in der Sidebar **Läuft jetzt**
|
||||
- Bei „Wenn frei“: Eintrag unter [Warteschlange](Anleitung-Warteschlange.md)
|
||||
- Bei Warteschlange / Nächster passender: Eintrag unter [Warteschlange](Anleitung-Warteschlange.md)
|
||||
- Später unter **Protokoll** mit **Nochmal** (inkl. gespeicherter ACE-Map)
|
||||
|
||||
## Aus Orca
|
||||
@@ -53,6 +55,7 @@ Nach dem G-code-Export kann Orca die Datei direkt nach Grotti laden und diese Se
|
||||
|
||||
## Weiter
|
||||
|
||||
- [Projekte](Anleitung-Projekte.md)
|
||||
- [Orca an Grotti](Anleitung-Orca.md)
|
||||
- [ACE und Spulen](Anleitung-ACE.md)
|
||||
- [Warteschlange](Anleitung-Warteschlange.md)
|
||||
@@ -54,7 +54,7 @@ Dann <http://127.0.0.1:8770/> öffnen. Beim ersten Start legst du den Admin-Benu
|
||||
|
||||
## Sicherheit
|
||||
|
||||
- Druckstart nur mit Bestätigung (Zwei-Klick in der UI bzw. `confirm: true` in der API).
|
||||
- Druckstart über die API nur mit `confirm: true` (Dateien-UI: ein Klick).
|
||||
- Keine freie G-code-Konsole; Firmware-Neustart über Grotti ist gesperrt.
|
||||
- Benutzer und API-Tokens unter Einstellungen. Für Zugriff aus dem Internet: Reverse Proxy mit TLS, nicht den Entwickler-Server öffentlich freigeben.
|
||||
- `config.json` und `data/` bleiben lokal, nicht im Git.
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
# Einstellungen
|
||||
|
||||
Stand **17.10.2026**. Seite `/einstellungen` — Drucker, Automatik, Anzeige, Telegram, Energie.
|
||||
Stand **18.10.2026**. Seite `/einstellungen` — Drucker, Automatik, Anzeige, Telegram/ntfy, Energie.
|
||||
|
||||

|
||||
|
||||
## Dieser Browser
|
||||
|
||||
- **Desktop** oder **Regal (Touch)** — nur lokal im Browser, siehe [Oberfläche](Anleitung-Oberflaeche.md).
|
||||
- **Darstellung** — Dunkel, Hell oder System (folgt dem Gerät).
|
||||
- **Zeitzone** — Browser automatisch oder fest (z. B. Europe/Berlin) für Protokoll, Bericht und Aktivität.
|
||||
|
||||
## Automatik
|
||||
|
||||
Checkbox **Automatik-Warteschlange** (`auto_queue`). Aus = keine neuen „wenn frei“-Starts. Laufende Abläufe und manuelle „Jetzt“-Starts bleiben möglich.
|
||||
Checkbox **Automatik-Warteschlange** (`auto_queue`). Aus = keine neuen Queue-Starts. Laufende Abläufe und manuelle Sofort-Starts bleiben möglich.
|
||||
|
||||
## Slice-Ordner
|
||||
|
||||
@@ -19,29 +20,30 @@ Pfad zum Ordner, den die Bibliothek beobachtet (Export aus Orca/Anycubic, **Ordn
|
||||
|
||||
## Drucker
|
||||
|
||||
Tabelle: Name, Host, Familie, **gesperrt**.
|
||||
Pro Drucker: Name, Host, Familie, **gesperrt**, optional **Start-Zeitfenster**.
|
||||
|
||||
- Seed beim allerersten Start aus `drucker.json`, danach nur noch hier (Datenbank).
|
||||
- Familie aus dem Namen (Kobra 3 / S1) oder gesetzt.
|
||||
- **Gesperrt:** keine neuen Queue-Starts auf diesem Host.
|
||||
- **Zeitfenster:** Queue startet nur zwischen Von/Bis (z. B. 22:00–06:00). Optional Max.-Stunden für Nachtjobs.
|
||||
|
||||
## FQDN
|
||||
|
||||
Öffentliche Basis-URL für QR-Codes auf Spulen-Etiketten. Leer = Host aus dem aktuellen Request. Wichtig, wenn du Etiketten von einem Handy scannst, das nicht `127.0.0.1` kennt.
|
||||
|
||||
## Telegram und Alerts
|
||||
## Telegram, ntfy und Alerts
|
||||
|
||||
Bot-Token und Chat-ID. Pro Ereignisart an/aus (Fertig, Fehler, Offline, Queue, …). Test-Knopf zum Prüfen. Wichtige Ereignisse stehen auch in der Sidebar unter Alerts.
|
||||
Telegram: Bot-Token und Chat-ID, pro Ereignisart an/aus, Test-Knopf. **ntfy:** Server, Topic, optional Token — gleiche Meldungen, unabhängig schaltbar, ebenfalls mit Test. Ereignisse stehen auch in der Sidebar unter Alerts.
|
||||
|
||||
## Benutzer und API-Tokens
|
||||
|
||||
Unter **Benutzer** (nur Admin) legst du Konten mit Rolle Admin oder Operator an. Unter **API-Tokens** erzeugst du Schlüssel für Home-Automation, App oder MCP — der Klartext erscheint nur einmal (`Authorization: Bearer grt_…`).
|
||||
Unter **Benutzer** (nur Admin) legst du Konten mit Rolle Admin oder Operator an. Unter **API-Tokens** erzeugst du Schlüssel für Home-Automation, App oder MCP — der Klartext erscheint nur einmal (`Authorization: Bearer grt_…`). Ein Shared-`token` in der Config-Datei gilt nicht mehr.
|
||||
|
||||
MCP (optional): Token erzeugen, dann `python -m grotti.mcp_server` mit `GROTTI_URL` und `GROTTI_TOKEN`. Für nur Lesen: `GROTTI_MCP_READ_ONLY=1`.
|
||||
|
||||
## Energie
|
||||
|
||||
**Grundlast W** (Elektronik/Motion, nicht die Heizer) und Strompreis für die Kosten im **Protokoll**. **Düse W** / **Bett W** zusammen mit ED-Proben im Verlauf → Schätzung „aus ED“; stehen sie auf 0, bleibt es bei Dauer × Grundlast („pauschal“). Typisch Kobra: Grundlast ~40 W, Düse ~60 W, Bett ~200–300 W. Beim Speichern warnt die Seite, wenn Düse oder Bett noch 0 sind. Optional Trockner/Ausschuss/Auslastung für die Kalkulation.
|
||||
**Grundlast W** (Elektronik/Motion, nicht die Heizer) und Strompreis für die Kosten im **Protokoll**. **Düse W** / **Bett W** zusammen mit ED-Proben im Verlauf → Schätzung „aus ED“; stehen sie auf 0, bleibt es bei Dauer × Grundlast („pauschal“). Typisch Kobra: Grundlast ~40 W, Düse ~60 W, Bett ~200–300 W. Optional **HT/NT-Tarif** mit Zeiten und Preisen — Grotti gewichtet dann die Stromkosten nach Uhrzeit. Beim Speichern warnt die Seite, wenn Düse oder Bett noch 0 sind.
|
||||
|
||||
## Weiter
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Material und mehr
|
||||
|
||||
Stand **17.10.2026**. Spulen, Etiketten, Projekte, Protokoll, Statistik, Kameras, Aktivität und Ideen.
|
||||
Stand **18.10.2026**. Spulen, Etiketten, Aufträge, Protokoll, Statistik, Aktivität.
|
||||
|
||||

|
||||
|
||||
@@ -8,7 +8,9 @@ Stand **17.10.2026**. Spulen, Etiketten, Projekte, Protokoll, Statistik, Kameras
|
||||
|
||||
Zwei Reiter: **Spulen** und **Sorten**.
|
||||
|
||||
**Spulen:** Filter Alle / Am Drucker / Regal / Wenig Rest. Formular bei Auswahl oder „Neue Spule“. Ort = Drucker · ACE/extern oder Regal. Scan-Feld im Seitenkopf (QR vom Etikett). Unter der ausgewählten Spule die **Akte**: verdruckte Gramm, Ausschuss, Leer-in.
|
||||
Oben die Karte **Bedarf aus Warteschlange**, wenn wartende Jobs Filament brauchen: Queue-Gewicht vs. Spulenrest je Sorte/Farbe. Fehlmengen erscheinen auch in der Sidebar und als Chip in der Warteschlange.
|
||||
|
||||
**Spulen:** Filter Alle / Am Drucker / Regal / Wenig Rest / Archiv; zusätzlich Sorte und Farbe. Leere und archivierte Spulen sind standardmäßig ausgeblendet — **Archiv** zeigt sie. Formular bei Auswahl oder „Neue Spule“. **Leer** setzt Rest auf 0, legt die Rolle ins Regal und archiviert; **Archivieren** / **Wiederherstellen** ohne Restzwang. Ort = Drucker · ACE/extern oder Regal. Scan-Feld im Seitenkopf (QR vom Etikett). Unter der ausgewählten Spule die **Akte**: verdruckte Gramm, Ausschuss, Leer-in.
|
||||
|
||||
**Sorten:** Profile (Dichte, Preis, Maschinen). „Spule damit anlegen“ wechselt zum Spulen-Reiter.
|
||||
|
||||
@@ -18,15 +20,17 @@ Pro Spule ein Etikett mit Chargennummer `S-…` und QR. Der Code öffnet Materia
|
||||
|
||||
Druck-Etiketten (`D-…`) kommen vom Druckbericht: ein Etikett für den ganzen Lauf oder je Objekt. Scan von `D-…` öffnet den Bericht.
|
||||
|
||||
## Projekte
|
||||
## Aufträge
|
||||
|
||||
Unter **Projekte** Aufträge mit Notiz und Kalkulation. Dateien lassen sich einem Projekt zuweisen (Bibliothek / Senden).
|
||||
Unter **Aufträge** Kunden, Positionen und Fortschritt. Dateien lassen sich einem Auftrag zuweisen; siehe [Aufträge](Anleitung-Auftraege.md).
|
||||
|
||||
## Protokoll und Statistik
|
||||
|
||||
Abgeschlossene Läufe: Dauer, Ist-Gramm, Kosten, Urteil. Zeile öffnet den **Druckbericht** (Soll/Ist, Spulen, Temperaturverlauf, Charge). **Nochmal** startet denselben Job inkl. gespeicherter ACE-Map und Bettnetz-Option.
|
||||
Abgeschlossene Läufe: Dauer, Ist-Gramm, Kosten, Urteil. Zeile öffnet den **Druckbericht** (Soll/Ist, Spulen, Temperaturverlauf, Kosten-Aufschlüsselung Filament + Strom mit Tortendiagramm, Charge). **Nochmal** startet denselben Job inkl. gespeicherter ACE-Map und Bettnetz-Option.
|
||||
|
||||
Unter **Statistik** Auslastung, Erfolg, Verbrauch und CSV-Export. Zeitraum per Knopf (Tag, Woche, Monat, Gestern, Letzte Woche, Letzter Monat) oder eigene Von/Bis-Daten.
|
||||
Nach Filament-Ende: neue Spule unter **Material** dem Drucker/ACE-Platz zuweisen (Ort setzen und speichern — das legt den Rollenwechsel im Lauf an), am Gerät laden, an der Wand **Weiter**. Nur physisch nachlegen reicht nicht. Die Zuweisung gilt auch, wenn Moonraker den Druck nach der Pause als neuen Job zählt.
|
||||
|
||||
Unter **Statistik** KPIs, Balkendiagramme (Druckzeit je Drucker, Verbrauch je Material), Tabellen und CSV-Export. Zeitraum per Knopf oder Von/Bis.
|
||||
|
||||
Druck-Etiketten (`D-…`) schon während des Drucks: an der Wand **Etikett**, im Druckbericht **Etikett Druck** / **Etiketten Objekte**. Auf dem Bogen steht das Sollgewicht aus dem Slicer (geplante Filamentmenge). Bei Ausschuss die gedruckten Bögen wegwerfen; die Charge bleibt im Protokoll.
|
||||
|
||||
@@ -34,7 +38,7 @@ Abzug nach dem Druck nimmt das Ist aus `filament_used` (Abbruch nur anteilig). M
|
||||
|
||||
## Kameras
|
||||
|
||||
`/kameras` — alle Snapshots nebeneinander mit Live-Zustand. Praktisch auf dem Regal-Tablet.
|
||||
Auf der **Druckerwand** erscheint je Drucker ein kleines Snapshot-Bild (ca. alle 3 s), wenn eine Webcam erreichbar ist. Klick öffnet das Cockpit — dort gilt weiter die Einstellung Snapshot/Stream unter Einstellungen → Dieser Browser. Die frühere Seite `/kameras` leitet auf die Wand um.
|
||||
|
||||
## Aktivität
|
||||
|
||||
|
||||
@@ -1,38 +1,49 @@
|
||||
# Oberfläche
|
||||
|
||||
Stand **15.10.2026**. Wie die App aufgebaut ist — Menü, Live-Leiste, Desktop und Regal.
|
||||
Stand **18.10.2026**. Wie die App aufgebaut ist — Menü, Jetzt-Liste, Desktop und Regal.
|
||||
|
||||
## Navigation
|
||||
|
||||
Links (Desktop): **Drucker**, **Dateien**, **Kameras**, **Aktivität**, **Warteschlange**, **Protokoll**, **Statistik**, **Material**, **Projekte**. Darunter **Einstellungen**, **Ideen**, **Doku**. Fuß: Version (öffnet die Doku).
|
||||
Links (Desktop), gruppiert: **Live** (Drucker, Dateien, Warteschlange, Aufträge), **Rückschau** (Protokoll, Statistik, Aktivität), **Verwalten** (Projekte, Material, Wartung). Darüber im Inhaltsbereich die **Topbar**: Suche, Live-Chips, Einstellungen, Doku und Benutzer-Menü (Abmelden, Version).
|
||||
|
||||
Unter ca. 760 px: untere Leiste mit den wichtigsten Seiten. Das Seitenmenü lässt sich zur Icon-Rail einklappen.
|
||||
Unter ca. 760 px: untere Leiste (Drucker, Dateien, Warteschlange, **Jetzt**, Mehr) plus **Mehr**-Sheet (inkl. Aktivität und Abmelden). Das Seitenmenü lässt sich zur Icon-Rail einklappen.
|
||||
|
||||

|
||||
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):
|
||||
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. |
|
||||
| **Wartet** | Wartende Aufträge: Priorität, Abbrechen. Gestoppte Abläufe mit Fortsetzen. |
|
||||
| **Aufgaben** | Platte belegt ohne laufenden Ablauf — **geräumt** (Zwei-Klick). |
|
||||
| **Senden** | Uploads mit MB-Fortschritt. |
|
||||
| **Alerts** | Ungelesene wichtige Ereignisse; als gelesen markieren oder zur Aktivität. |
|
||||
| Platte räumen | Quittieren mit Urteil (optional „gleich nochmal“) |
|
||||
| Material nachlegen | Druck pausiert, Filament vermutlich leer (ACE/Runout) — Spule zuweisen, dann an der Wand **Weiter** |
|
||||
| Druck fehlgeschlagen | Fortsetzen / Ausblenden |
|
||||
| Spule fehlt | Link zu Material / Bedarf |
|
||||
| Wartung fällig | Link zu Wartung |
|
||||
| Auftrag überfällig | Link zu Aufträgen |
|
||||
| Wartet auf Material | Wartegrund aus dem Routing |
|
||||
|
||||
Aktualisierung über Live-Stream (SSE), sonst Polling.
|
||||
|
||||
## Desktop und Regal
|
||||
|
||||
Unter **Einstellungen → Dieser Browser**:
|
||||
|
||||
- **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
|
||||
- **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). Gilt für das Cockpit; Wand-Thumbs sind immer Snapshot.
|
||||
|
||||
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
|
||||
|
||||
- [Druckerwand](Anleitung-Wand.md)
|
||||
- [Dateien senden](Anleitung-Dateien.md)
|
||||
- [Projekte](Anleitung-Projekte.md)
|
||||
@@ -1,6 +1,6 @@
|
||||
# Orca an Grotti
|
||||
|
||||
Stand **17.10.2026**. Nach dem Export in OrcaSlicer landet der G-code in der Bibliothek und die Seite [Dateien senden](Anleitung-Dateien.md) öffnet sich mit der Datei schon gewählt. Drucker, ACE-Platz und Einreihen wählst du dort wie gewohnt.
|
||||
Stand **18.10.2026**. Nach dem Export landet der G-code in der Bibliothek und Grotti öffnet sich als **eigenes Fenster** (Chrome/Edge-App auf dem Desktop). Drucker, ACE-Platz und Einreihen wählst du dort.
|
||||
|
||||
## Einmal einrichten
|
||||
|
||||
@@ -11,11 +11,13 @@ Stand **17.10.2026**. Nach dem Export in OrcaSlicer landet der G-code in der Bib
|
||||
```json
|
||||
{
|
||||
"url": "http://127.0.0.1:8770",
|
||||
"token": "grt_…"
|
||||
"token": "grt_…",
|
||||
"browser": "app"
|
||||
}
|
||||
```
|
||||
|
||||
`url` ist die Adresse, unter der du Grotti im Browser öffnest.
|
||||
`browser`: `app` (Standard) = eigenes Desktop-Fenster; `tab` = klassischer Browser-Tab.
|
||||
|
||||
4. Im **Prozessprofil** (Druckqualität, z. B. „0.20mm Standard“): **Sonstiges → Nachbearbeitungsskripte**, eine Zeile:
|
||||
|
||||
@@ -25,13 +27,31 @@ D:\Projekte\Grotti\.venv\Scripts\python.exe D:\Projekte\Grotti\scripts\orca_send
|
||||
|
||||
Gilt für **OrcaSlicer** und **AnycubicSlicerNext**. Pfade anpassen. Der Slicer hängt den Export-Pfad selbst an.
|
||||
|
||||
Unter **Gerät / Drucker** keinen Print-Host (Moonraker) eintragen — sonst geht der Job am Grotti-Senden vorbei.
|
||||
### Gerät-Reiter = Grotti (nur OrcaSlicer)
|
||||
|
||||
## Ablauf
|
||||
Damit unter **Gerät** die Grotti-Oberfläche erscheint:
|
||||
|
||||
1. In Orca slicen und **G-code exportieren** (Platte schreiben). Als Ziel den Slice-Ordner wählen — **Unterordner** (z. B. `slice\Gridfinity\…`) gehen: Grotti legt die Datei dort ab und liest den Watch neu. Export außerhalb des Slice-Ordners landet als Upload (ohne Ordner-Verschieben).
|
||||
2. Der Browser öffnet `/dateien?file=…` mit der neuen Datei.
|
||||
3. Drucker wählen, unter Optionen Farbe → ACE-Platz, **Jetzt** / **Wenn frei** / **Nächster freier**, dann **Starten**.
|
||||
1. Orca → Drucker-Dropdown → **Physikalischer Drucker** (Verbindung/Stecker-Symbol).
|
||||
2. **Host-Typ:** z. B. `Octo/Klipper` (egal, solange du nicht „Drucken“ an den Host schickst).
|
||||
3. **Hostname / API:** leer lassen — oder nur eine Dummy-Adresse. **Nicht** die Moonraker-IP eines Kobras eintragen.
|
||||
4. **Device UI / Web-UI-URL:** `http://127.0.0.1:8770/` (Grotti muss laufen).
|
||||
5. Speichern → Reiter **Gerät** öffnen → einmal bei Grotti anmelden.
|
||||
|
||||
Wichtig: Den Orca-Button **Drucken / Upload zum Host** nicht nutzen — der spricht Moonraker/OctoPrint und umgeht ACE-Zuordnung in Grotti. Weiterhin **G-code exportieren** + Nachbearbeitungsskript.
|
||||
|
||||
In **AnycubicSlicerNext** steckt unter Gerät/Arbeitsfläche die Anycubic-Cloud — dort lässt sich Grotti so nicht einbinden.
|
||||
|
||||
## Ablauf (wichtig bei Anycubic)
|
||||
|
||||
**Nicht** „Remote Print“ / „Aufgabe starten“ / Cloud-Dialog — der Anycubic-Dialog lässt sich von Grotti nicht abschalten.
|
||||
|
||||
Stattdessen:
|
||||
|
||||
1. Slicen, dann **G-code exportieren** / **Platte schreiben** (Ziel: Slice-Ordner; Unterordner ok).
|
||||
2. Das Nachbearbeitungsskript lädt die Datei nach Grotti und öffnet das Grotti-Fenster.
|
||||
3. Drucker wählen, unter Optionen Farbe → ACE-Platz, **Dieser Drucker** oder **Nächster passender**, dann **Starten**.
|
||||
|
||||
Erstes App-Fenster: einmal bei Grotti anmelden (eigenes Profil unter `data/grotti-app-profile`). Die Session hält **30 Tage** und überlebt das Schließen des Fensters. Läuft das Fenster noch, startet Orca kein zweites Chrome (sonst leeres Profil → erneutes Login) — die neue Datei wird im offenen Fenster ausgewählt.
|
||||
|
||||
Test ohne Orca:
|
||||
|
||||
@@ -43,11 +63,20 @@ D:\Projekte\Grotti\.venv\Scripts\python.exe D:\Projekte\Grotti\scripts\orca_send
|
||||
|
||||
| Symptom | Check |
|
||||
| --- | --- |
|
||||
| Anycubic-Dialog trotz Grotti | Du hast Remote Print / Aufgabe starten genutzt → nur **G-code exportieren** |
|
||||
| Meldungsfenster „Config fehlt“ | `data/orca-senden.json` anlegen |
|
||||
| „Grotti nicht erreichbar“ | Server läuft? `url` stimmt? |
|
||||
| „Token fehlt oder stimmt nicht“ | Token neu anlegen, in der JSON eintragen |
|
||||
| Browser ohne Datei / Login | Einmal anmelden; Deep-Link behält `?file=` nach dem Login |
|
||||
| Skript läuft nicht | Export wirklich speichern; Zeile im **Druckerprofil**; Python-Pfad prüfen |
|
||||
| App-Fenster will Login | Einmal anmelden (Session 30 Tage). Profil: `data/grotti-app-profile`. Zweites Chrome-Fenster schließen, nur eines offen lassen. Grotti neu starten nach Update. |
|
||||
| Browser-Tab statt Fenster | In `orca-senden.json` `"browser": "app"` setzen (oder Chrome/Edge fehlt) |
|
||||
| Zwei Fenster / Tab hängt | Debounce (~25 s); erneuter Export aktualisiert den offenen Tab. 3D-Pfad lädt verzögert |
|
||||
| ACE zeigt T0/T1 statt T1/T10 | Seite neu laden oder **Ordner neu lesen**; Deep-Link zieht `paint_index` nach |
|
||||
| Skript läuft nicht | Export wirklich speichern; Zeile im **Prozessprofil**; Python-Pfad prüfen |
|
||||
|
||||
## Was Grotti nicht kann
|
||||
|
||||
- Den **Anycubic-Cloud-/Aufgabe-Dialog** unterdrücken (liegt im Slicer, nicht im Skript).
|
||||
- Sich in Anycubics Reiter **Arbeitsfläche** einbetten. In **Orca** dagegen: Device-UI-URL → Grotti unter **Gerät** (siehe oben).
|
||||
|
||||
## Weiter
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# Projekte
|
||||
|
||||
Stand **18.10.2026**. Arbeitsmappen unter `/projekte` — getrennt von Kunden-[Aufträgen](Anleitung-Auftraege.md).
|
||||
|
||||
## Wozu
|
||||
|
||||
Ein **Projekt** bündelt Konstruktion und Druckdateien in einem Ordner unter dem [Slice-Pfad](Anleitung-Einstellungen.md), z. B. `Projekte/2609_002`.
|
||||
|
||||
| | Projekt | Auftrag |
|
||||
| --- | --- | --- |
|
||||
| Zweck | Konstruktion + Slice-Dateien | Kunde, Menge, Lieferschein |
|
||||
| Ort | Ordner auf der Platte | nur in der Datenbank |
|
||||
| Seite | `/projekte` | `/auftraege` |
|
||||
|
||||
## Anlegen
|
||||
|
||||
**Neues Projekt** — Name, Status, optional Ordner (Standard: `Projekte/<Kurzname>`). Mit gesetztem Slice-Ordner legt Grotti den Ordner an.
|
||||
|
||||
G-code und 3MF in diesem Ordner (oder dorthin [verschoben](Anleitung-Dateien.md)) werden dem Projekt zugeordnet und erscheinen in der Druck-Bibliothek.
|
||||
|
||||
## Weitere Dateien
|
||||
|
||||
Im Projekt-Detail: Upload von STEP, STL, PDF, SolidWorks usw. — liegen im selben Ordner, erscheinen **nicht** in der Druck-Bibliothek. Download und Löschen pro Datei.
|
||||
|
||||
## Löschen
|
||||
|
||||
Projekt löschen (Zwei-Klick) entfernt den Datensatz und den Ordner **inkl. Inhalt** (`force`). Druckdateien in der Bibliothek verlieren nur die Projekt-Zuordnung, wenn sie woanders liegen; Dateien im Projektordner sind mit dem Ordner weg.
|
||||
|
||||
## Weiter
|
||||
|
||||
- [Dateien](Anleitung-Dateien.md)
|
||||
- [Aufträge](Anleitung-Auftraege.md)
|
||||
- [Einstellungen](Anleitung-Einstellungen.md)
|
||||
@@ -1,6 +1,6 @@
|
||||
# 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.
|
||||
|
||||

|
||||
|
||||
@@ -9,6 +9,7 @@ Stand **15.10.2026**. Startseite `/` — eine Karte je Drucker. Klick öffnet da
|
||||
Pro Drucker siehst du:
|
||||
|
||||
- Zustand (Chip), Fertig-Uhrzeit, Fortschrittsbalken
|
||||
- Kleines Kamerabild (Snapshot ca. alle 3 s), wenn eine Webcam erreichbar ist — Klick öffnet das Cockpit
|
||||
- Restzeit, Schicht, Laufzeit
|
||||
- Temperaturen Düse/Bett (mit Ziel)
|
||||
- Dateiname, bei ACE oft Farb-Swatches
|
||||
@@ -18,7 +19,7 @@ Aktionen auf der Karte brauchen oft **zwei Klicks** (erster Klick „scharf“,
|
||||
|
||||
| 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 |
|
||||
| **Fehler löschen** | Zustand Fehler — räumt den hängenden Fehler, Heizung aus |
|
||||
| **Platte geräumt** | Nach Fertig, wenn die Platte wieder frei ist |
|
||||
@@ -34,11 +35,11 @@ URL mit Hash: `/#<host>` (z. B. nach Klick auf die Karte).
|
||||
|
||||
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 (gilt nur im Cockpit; die Wand-Thumbs sind immer Snapshot)
|
||||
- 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)
|
||||
- **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 Kosten: Filament + Grundlast/Düse/Bett)
|
||||
- **3D-Pfad** der laufenden oder gewählten Datei; Objekte ausschließen (mit Bestätigung)
|
||||
- **Bettnetz**-Heatmap, wenn vorhanden
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Warteschlange und Ablauf
|
||||
|
||||
Stand **15.10.2026**. Aufträge „wenn frei“, Priorität, und der Kobra-3-Ablauf (drucken → abkühlen → schieben → tasten).
|
||||
Stand **18.10.2026**. Aufträge in der Warteschlange (Dieser Drucker / Nächster passender), Priorität, und der Kobra-3-Ablauf (drucken → abkühlen → schieben → tasten).
|
||||
|
||||

|
||||
|
||||
@@ -18,9 +18,11 @@ Unter **Läuft** siehst du auch Direktstarts ohne Queue-Zeile (Link zur Wand).
|
||||
|
||||
Dieselbe Information steht kompakt in der Sidebar (**Läuft jetzt** / **Wartet**). Unter **Läuft jetzt** eine Zeile pro Drucker; Klick öffnet diese Seite, × bricht ab.
|
||||
|
||||
Reicht der Spulenrest nicht für wartende Jobs, erscheint oben ein **Fehlmenge**-Hinweis (Details unter Material → Bedarf) und in der Sidebar unter **Bedarf**.
|
||||
|
||||
## Wann startet ein wartender Auftrag?
|
||||
|
||||
Sobald der Drucker bereit ist (`standby` / fertig / abgebrochen), die **Platte frei** ist und der Drucker nicht gesperrt. Automatik aus (`auto_queue`) oder gesperrt → keine neuen Starts; laufende Abläufe laufen weiter.
|
||||
Sobald der Drucker bereit ist (`standby` / fertig / abgebrochen), die **Platte frei** ist und der Drucker nicht gesperrt. Automatik aus (`auto_queue`) oder gesperrt → keine neuen Starts; laufende Abläufe laufen weiter. Mit **Start-Zeitfenster** in den Einstellungen nur innerhalb von Von/Bis.
|
||||
|
||||
## Ablauf (nur Kobra 3)
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 213 KiB |
|
After Width: | Height: | Size: 204 KiB |
|
After Width: | Height: | Size: 156 KiB |
|
After Width: | Height: | Size: 158 KiB |
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 245 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 142 KiB |
|
After Width: | Height: | Size: 219 KiB |
|
After Width: | Height: | Size: 194 KiB |
@@ -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.
|
||||
@@ -6,7 +6,7 @@ Nach dem Druck kann Grotti auf der **Kobra 3** Bett und Düse abkühlen und die
|
||||
|
||||
## 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“:** Quittierung + optional Urteil. Tabelle `bed_status`, API `POST /api/v1/drucker/<host>/abraeumen` mit `confirm`. Fährt keine Bahn. Offener Live-Lauf wird als `completed` geschlossen (History von Moonraker oft ohne Endzeit). `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.
|
||||
- **Normale Drucke** und „Nochmal“ schieben nicht — nach dem Druck muss man auf der Wand quittieren.
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ In der Bibliothek gespeichert:
|
||||
|
||||
| Feld | Bedeutung |
|
||||
|------|-----------|
|
||||
| `filaments` | Typ/Farbe (`paint_info` oder `filament_type`/`filament_colour`) |
|
||||
| `filaments` | Typ/Farbe (`paint_info` oder `filament_type`/`filament_colour`) plus `profile` aus `filament_settings_id` (Sorte, z. B. Sunlu PLA Meta) |
|
||||
| `weight_g` / `estimated_s` | Filamentgewicht und Druckzeit |
|
||||
| `printer_family` | aus `printer_model` |
|
||||
| `slice_meta` | jsonb: `layer_height`, `layers`, `max_z_mm`, `nozzle_mm`, `temp_nozzle`, `temp_bed`, `slicer` |
|
||||
|
||||
@@ -1,33 +1,40 @@
|
||||
# Grotti: Alltag und Oberfläche
|
||||
|
||||
Stand **18.10.2026**, v3.0.0.
|
||||
Stand **18.10.2026**, v3.3.0.
|
||||
|
||||
## Design
|
||||
|
||||
- Dunkles UI mit Orange-Akzent, Lucide-ähnliche Stroke-Icons (SVG-Sprite in `layout.html`).
|
||||
- Tokens in `grotti.css`; `--faint` lesbar; `prefers-reduced-motion`; Touch-Ziele bei `pointer: coarse`.
|
||||
- Dunkles UI mit Orange-Akzent; Light-Theme über dieselben Tokens (`html[data-theme]`).
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- **Rückschau:** Protokoll, Statistik, Aktivität.
|
||||
- **Verwalten:** Material, Wartung; Einstellungen, Doku.
|
||||
- Live-Panel: Läuft / Wartet / Aufgaben / Senden / Alerts.
|
||||
- Mobile: Bottom-Nav Drucker · Dateien · Queue · Aktivität · **Mehr** (Sheet).
|
||||
- Sidebar: Abschnittslabels **Live** / **Rückschau** / **Verwalten** (ohne Live-Panel).
|
||||
- Topbar-Live-Chips (**Jetzt** · Läufe · Senden · Meldungen) mit Popover; Farm-ETA nur bei mehreren Läufen.
|
||||
- Mobile: Tab **Jetzt** (`/jetzt`) statt Aktivität in der unteren Leiste; Aktivität unter Mehr.
|
||||
- Topbar: Suche, Einstellungen, Doku, Benutzer-Menü (Avatar → Abmelden, Version, Copyright).
|
||||
- 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
|
||||
|
||||
- Karten + Cockpit wie bisher.
|
||||
- Karten + Cockpit; Technik-Fold mit Icons je Abschnitt.
|
||||
- **Platte geräumt** öffnet Dialog: gut/Ausschuss, Grund, Stückzahlen.
|
||||
|
||||
## 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.
|
||||
|
||||
## 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.
|
||||
@@ -12,7 +12,7 @@ Seite `/einstellungen`: Druckernamen/Host/Familie/gesperrt (PostgreSQL `printers
|
||||
|
||||
## Druckwarteschlange „wenn frei“
|
||||
|
||||
Dateien → „Wenn frei“ oder „Nächster freier“ → `when_free` in `POST /api/v1/senden`. Einträge in `print_queue`; Dispatcher startet, sobald der Host (oder ein freier Drucker) `standby`/`complete`/`cancelled` ist, Platte frei und nicht gesperrt. **Alle freien Drucker im selben Tick** (Upload/Start je Host im Worker parallel) — nicht einer alle ~8 s. Sidebar und Seite `/warteschlange`. Sofort-Senden bleibt über die Send-Queue (mehrere Worker). Hängender Fehler wird nur geräumt, wenn für den Host ein Auftrag wartet. Ablauf: Phase `starting` vor Upload; Timeout 10 min ohne `print_active`.
|
||||
Dateien → „Dieser Drucker“ (belegt) oder „Nächster passender“ → `when_free` in `POST /api/v1/senden`. Einträge in `print_queue`; Dispatcher startet, sobald der Host (oder ein freier Drucker) `standby`/`complete`/`cancelled` ist, Platte frei und nicht gesperrt. Material-Score: Katalog-Sorte aus `filament_settings_id` (streng), Farbe soft; bei ACE und leerer Map baut Grotti die Zuordnung aus Spulen. **Alle freien Drucker im selben Tick** (Upload/Start je Host im Worker parallel) — nicht einer alle ~8 s. Sidebar und Seite `/warteschlange`. Sofort-Senden bleibt über die Send-Queue (mehrere Worker). Hängender Fehler wird nur geräumt, wenn für den Host ein Auftrag wartet. Ablauf: Phase `starting` vor Upload; Timeout 10 min ohne `print_active`.
|
||||
|
||||
## Spulen
|
||||
|
||||
|
||||
@@ -73,13 +73,182 @@ def normalize_mapping(raw):
|
||||
ams = int(item.get("ams_index"))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if paint < 0 or ams < 0 or ams > 7 or paint in seen:
|
||||
# paint_index = Slicer-T (oft 0–15 bei Bambu), ams_index = ACE-Platz 0–7
|
||||
if paint < 0 or paint > 63 or ams < 0 or ams > 7 or paint in seen:
|
||||
continue
|
||||
seen.add(paint)
|
||||
out.append({"paint_index": paint, "ams_index": ams})
|
||||
return out
|
||||
|
||||
|
||||
def filament_for_paint(filaments, paint):
|
||||
"""Filament-Eintrag zur Slicer-T-Nummer; Fallback Array-Index für Alt-Daten."""
|
||||
filaments = filaments or []
|
||||
try:
|
||||
paint = int(paint)
|
||||
except (TypeError, ValueError):
|
||||
return {}
|
||||
for index, row in enumerate(filaments):
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
try:
|
||||
key = int(row.get("paint_index"))
|
||||
except (TypeError, ValueError):
|
||||
key = index
|
||||
if key == paint:
|
||||
return row
|
||||
if 0 <= paint < len(filaments) and isinstance(filaments[paint], dict):
|
||||
return filaments[paint]
|
||||
return {}
|
||||
|
||||
|
||||
def densify_mapping(mapping):
|
||||
"""Slicer-T mit Lücken (T1/T10) → dichte 0…n−1 für gklib/ACE.
|
||||
|
||||
gklib meldet bei zu hoher Filament-ID ``invalid filament id`` (10011700).
|
||||
Rückgabe: (remap alt→neu, dichte Map). Identity → ({}, unverändert).
|
||||
"""
|
||||
mapping = normalize_mapping(mapping)
|
||||
paints = sorted(item["paint_index"] for item in mapping)
|
||||
if not paints or paints == list(range(len(paints))):
|
||||
return {}, mapping
|
||||
remap = {old: new for new, old in enumerate(paints)}
|
||||
dense = [
|
||||
{"paint_index": remap[item["paint_index"]], "ams_index": item["ams_index"]}
|
||||
for item in mapping
|
||||
]
|
||||
return remap, dense
|
||||
|
||||
|
||||
def remap_filaments(filaments, remap):
|
||||
"""paint_index der Filament-Metadaten mit densify-Remap mitziehen."""
|
||||
if not remap:
|
||||
return list(filaments or [])
|
||||
out = []
|
||||
for row in filaments or []:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
copy = dict(row)
|
||||
try:
|
||||
old = int(row.get("paint_index"))
|
||||
except (TypeError, ValueError):
|
||||
out.append(copy)
|
||||
continue
|
||||
if old in remap:
|
||||
copy["paint_index"] = remap[old]
|
||||
out.append(copy)
|
||||
return out
|
||||
|
||||
|
||||
def align_tools_for_ace(content, mapping, filaments=None):
|
||||
"""G-code-T und ACE-Map auf dichte 0…n−1 bringen.
|
||||
|
||||
Maßgeblich sind die ``Tn`` im G-code. Die UI-Map darf echte Slicer-Indizes
|
||||
(T1/T10) oder fälschlich kompakte 0/1 haben — ACE-Plätze werden in
|
||||
Sortierreihenfolge der Map-Einträge den sortierten T-Nummern zugeordnet.
|
||||
Sonst: ACM 0/1 + G-code T10 → 10011700 invalid filament id.
|
||||
"""
|
||||
from grotti.parse import tools_used_in_gcode
|
||||
|
||||
mapping = normalize_mapping(mapping)
|
||||
used = sorted(tools_used_in_gcode(content))
|
||||
if not mapping:
|
||||
return content, mapping, list(filaments or []), {}
|
||||
if not used:
|
||||
remap, dense = densify_mapping(mapping)
|
||||
new_content = rewrite_gcode_tools(content, remap) if remap else content
|
||||
return new_content, dense, remap_filaments(filaments, remap), remap
|
||||
|
||||
if len(mapping) != len(used):
|
||||
from grotti.errors import GrottiError
|
||||
|
||||
raise GrottiError(
|
||||
"ACE-Zuordnung (%d Farben) passt nicht zum G-code (T%s). "
|
||||
"Datei neu lesen und Zuordnung erneut setzen."
|
||||
% (len(mapping), ", T".join(str(t) for t in used)),
|
||||
"ace",
|
||||
)
|
||||
|
||||
mapped_paints = {item["paint_index"] for item in mapping}
|
||||
paints_match_gcode = mapped_paints == set(used)
|
||||
if paints_match_gcode:
|
||||
ams_by_tool = {item["paint_index"]: item["ams_index"] for item in mapping}
|
||||
ams_order = [ams_by_tool[tool] for tool in used]
|
||||
else:
|
||||
# UI kompakt 0…n−1, G-code sparse — Reihenfolge der Map = Reihenfolge der Farben
|
||||
ordered = sorted(mapping, key=lambda item: item["paint_index"])
|
||||
ams_order = [item["ams_index"] for item in ordered]
|
||||
|
||||
remap = {old: new for new, old in enumerate(used)}
|
||||
dense = [
|
||||
{"paint_index": index, "ams_index": ams_order[index]}
|
||||
for index in range(len(used))
|
||||
]
|
||||
|
||||
source = list(filaments or [])
|
||||
if paints_match_gcode:
|
||||
new_fils = remap_filaments(source, remap)
|
||||
else:
|
||||
# Metadaten folgen der Map-Reihenfolge (0,1,…), nicht den G-code-T
|
||||
ordered_paints = sorted(item["paint_index"] for item in mapping)
|
||||
new_fils = []
|
||||
for index, tool in enumerate(used):
|
||||
row = filament_for_paint(source, ordered_paints[index]) or (
|
||||
source[index] if index < len(source) and isinstance(source[index], dict) else {}
|
||||
)
|
||||
if not row:
|
||||
row = filament_for_paint(source, tool) or {}
|
||||
copy = dict(row) if isinstance(row, dict) else {}
|
||||
copy["paint_index"] = index
|
||||
new_fils.append(copy)
|
||||
|
||||
# Identity-Remap weglassen (schon T0…n−1)
|
||||
if used == list(range(len(used))):
|
||||
return content, dense, new_fils, {}
|
||||
new_content = rewrite_gcode_tools(content, remap)
|
||||
return new_content, dense, new_fils, remap
|
||||
|
||||
|
||||
_TOOL_NUM_RE = re.compile(
|
||||
rb"^(?P<prefix>(?:MMU_CHANGE_TOOL(?:_STANDALONE)?[^\n]*?\bTOOL=)|T)(?P<num>\d{1,2})\b",
|
||||
re.I,
|
||||
)
|
||||
_REF_TOOLS_RE = re.compile(rb"^(;\s*referenced_tools\s*=\s*)(.+?)\s*$", re.I)
|
||||
|
||||
|
||||
def rewrite_gcode_tools(content, remap):
|
||||
"""Tn / TOOL=n / referenced_tools laut Remap umschreiben (T10 vor T1)."""
|
||||
if not content or not remap:
|
||||
return content
|
||||
text = content if isinstance(content, (bytes, bytearray)) else str(content).encode("utf-8", "replace")
|
||||
lines = text.splitlines(keepends=True)
|
||||
out = []
|
||||
for line in lines:
|
||||
stripped = line[:-2] if line.endswith(b"\r\n") else (line[:-1] if line.endswith(b"\n") else line)
|
||||
ending = line[len(stripped) :]
|
||||
ref = _REF_TOOLS_RE.match(stripped)
|
||||
if ref:
|
||||
parts = []
|
||||
for bit in ref.group(2).split(b","):
|
||||
raw = bit.strip()
|
||||
try:
|
||||
number = int(raw)
|
||||
except ValueError:
|
||||
parts.append(raw)
|
||||
continue
|
||||
parts.append(str(remap.get(number, number)).encode("ascii"))
|
||||
out.append(ref.group(1) + b",".join(parts) + ending)
|
||||
continue
|
||||
match = _TOOL_NUM_RE.match(stripped)
|
||||
if match:
|
||||
old = int(match.group("num"))
|
||||
if old in remap:
|
||||
new = str(remap[old]).encode("ascii")
|
||||
stripped = stripped[: match.start("num")] + new + stripped[match.end("num") :]
|
||||
out.append(stripped + ending)
|
||||
return b"".join(out)
|
||||
|
||||
|
||||
def build_acm(filaments, mapping, slots):
|
||||
filaments = filaments or []
|
||||
slots = slots or []
|
||||
@@ -88,7 +257,7 @@ def build_acm(filaments, mapping, slots):
|
||||
for item in normalize_mapping(mapping):
|
||||
paint = item["paint_index"]
|
||||
ams = item["ams_index"]
|
||||
filament = filaments[paint] if 0 <= paint < len(filaments) else {}
|
||||
filament = filament_for_paint(filaments, paint)
|
||||
slot = slot_by_index.get(ams) or {}
|
||||
paint_color = color_channels(filament.get("color")) or [0, 0, 0]
|
||||
ams_color = color_channels(slot.get("color")) or paint_color
|
||||
|
||||
@@ -15,7 +15,7 @@ def compute(conn):
|
||||
queue_rows = conn.execute(
|
||||
"""
|
||||
SELECT q.id, q.spool_id, q.repeat_total, q.repeat_done,
|
||||
f.weight_g, f.material_type AS file_material, f.color AS file_color,
|
||||
f.weight_g, f.filaments,
|
||||
s.material_type AS spool_material, s.color AS spool_color
|
||||
FROM print_queue q
|
||||
JOIN files f ON f.id = q.file_id
|
||||
@@ -26,8 +26,12 @@ def compute(conn):
|
||||
|
||||
needed_map = {}
|
||||
for row in queue_rows:
|
||||
mat = (row.get("spool_material") or row.get("file_material") or "").strip()
|
||||
color = (row.get("spool_color") or row.get("file_color") or "").strip()
|
||||
filaments = row.get("filaments") or []
|
||||
first = filaments[0] if isinstance(filaments, list) and filaments else {}
|
||||
if not isinstance(first, dict):
|
||||
first = {}
|
||||
mat = (row.get("spool_material") or first.get("type") or "").strip()
|
||||
color = (row.get("spool_color") or first.get("color") or "").strip()
|
||||
key = _group_key(mat, color)
|
||||
try:
|
||||
repeats = max(1, int(row.get("repeat_total") or 1) - int(row.get("repeat_done") or 0))
|
||||
|
||||
@@ -136,7 +136,7 @@ MATERIALS = [
|
||||
{
|
||||
"name": "Sunlu PA12-CF",
|
||||
"brand": "Sunlu",
|
||||
"material_type": "PA",
|
||||
"material_type": "PA12-CF",
|
||||
"density": 1.08,
|
||||
"volumetric": 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 = [
|
||||
{"id": "schwarz", "name": "Schwarz", "hex": "#1c1c1c"},
|
||||
{"id": "weiss", "name": "Weiß", "hex": "#f4f4f4"},
|
||||
|
||||
@@ -155,6 +155,7 @@ def load():
|
||||
"ntfy": _ntfy(raw),
|
||||
"alerts": _alerts(raw),
|
||||
"auto_queue": bool(raw.get("auto_queue", True)),
|
||||
"auto_retry": bool(raw.get("auto_retry", True)),
|
||||
"_raw": raw,
|
||||
"_path": path,
|
||||
}
|
||||
@@ -210,6 +211,7 @@ def settings_public(cfg):
|
||||
"watch_dir": str(cfg.get("watch_dir") or ""),
|
||||
"watch_dir_default": default_watch_suggestion(),
|
||||
"auto_queue": bool(cfg.get("auto_queue", True)),
|
||||
"auto_retry": bool(cfg.get("auto_retry", True)),
|
||||
"fqdn": str(cfg.get("fqdn") or ""),
|
||||
"legacy_token_set": bool(cfg.get("token")),
|
||||
"energy": dict(cfg.get("energy") or {}),
|
||||
@@ -243,6 +245,7 @@ def save_settings(
|
||||
ntfy=None,
|
||||
alerts=None,
|
||||
auto_queue=None,
|
||||
auto_retry=None,
|
||||
fqdn=None,
|
||||
watch_dir=None,
|
||||
):
|
||||
@@ -255,6 +258,7 @@ def save_settings(
|
||||
ntfy=ntfy,
|
||||
alerts=alerts,
|
||||
auto_queue=auto_queue,
|
||||
auto_retry=auto_retry,
|
||||
fqdn=fqdn,
|
||||
watch_dir=watch_dir,
|
||||
)
|
||||
@@ -267,6 +271,7 @@ def _save_settings_locked(
|
||||
ntfy=None,
|
||||
alerts=None,
|
||||
auto_queue=None,
|
||||
auto_retry=None,
|
||||
fqdn=None,
|
||||
watch_dir=None,
|
||||
):
|
||||
@@ -280,6 +285,9 @@ def _save_settings_locked(
|
||||
if auto_queue is not None:
|
||||
raw["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:
|
||||
resolved, stored = parse_watch_dir(watch_dir, create=True, missing_ok=False)
|
||||
raw["watch_dir"] = stored
|
||||
@@ -300,7 +308,7 @@ def _save_settings_locked(
|
||||
"utilization": float(energy.get("utilization", 50)),
|
||||
}
|
||||
tariff_in = energy.get("tariff") if isinstance(energy.get("tariff"), dict) else None
|
||||
if tariff_in is not None:
|
||||
if tariff_in:
|
||||
raw_energy["tariff"] = {
|
||||
"ht_euro": float(tariff_in.get("ht_euro", raw_energy["euro_per_kwh"])),
|
||||
"nt_euro": float(tariff_in.get("nt_euro", raw_energy["euro_per_kwh"])),
|
||||
|
||||
@@ -62,11 +62,13 @@ def _avg_power(a, b):
|
||||
return max(0.0, min(1.0, sum(vals) / len(vals)))
|
||||
|
||||
|
||||
def _ed_print_kwh(samples, base_w, nozzle_w, bed_w):
|
||||
"""Trapez aus Grundlast + ED×Heizerleistung. None wenn zu wenig ED-Proben."""
|
||||
def _ed_print_parts(samples, base_w, nozzle_w, bed_w):
|
||||
"""Trapez: Grundlast / Düse / Bett getrennt. None wenn zu wenig ED-Proben."""
|
||||
if not samples or len(samples) < 2:
|
||||
return None
|
||||
total = 0.0
|
||||
base_kwh = 0.0
|
||||
nozzle_kwh = 0.0
|
||||
bed_kwh = 0.0
|
||||
ed_steps = 0
|
||||
# Max. Intervall: 3× Normal-Sample (90 s), damit Lücken nicht die ED fortschreiben
|
||||
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:
|
||||
continue
|
||||
dt = min(t1 - t0, max_dt)
|
||||
hours = dt / 3600.0
|
||||
pn0, pn1 = a.get("nozzle_power"), b.get("nozzle_power")
|
||||
pb0, pb1 = a.get("bed_power"), b.get("bed_power")
|
||||
has_ed = any(
|
||||
@@ -87,11 +90,17 @@ def _ed_print_kwh(samples, base_w, nozzle_w, bed_w):
|
||||
ed_steps += 1
|
||||
ed_n = _avg_power(pn0, pn1)
|
||||
ed_b = _avg_power(pb0, pb1)
|
||||
watts = float(base_w) + float(nozzle_w) * ed_n + float(bed_w) * ed_b
|
||||
total += (watts / 1000.0) * (dt / 3600.0)
|
||||
base_kwh += (float(base_w) / 1000.0) * hours
|
||||
nozzle_kwh += (float(nozzle_w) * ed_n / 1000.0) * hours
|
||||
bed_kwh += (float(bed_w) * ed_b / 1000.0) * hours
|
||||
if ed_steps < 1:
|
||||
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):
|
||||
@@ -130,10 +139,21 @@ def effective_euro_per_kwh(duration_s, energy, end_dt=None):
|
||||
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):
|
||||
"""Strom aus ED-Proben oder pauschal Dauer × Watt.
|
||||
|
||||
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 {}
|
||||
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)
|
||||
bed_w = float(energy.get("bed_watts") or 0)
|
||||
|
||||
print_kwh = None
|
||||
parts_map = None
|
||||
source = "flat"
|
||||
if (nozzle_w > 0 or bed_w > 0) and samples:
|
||||
print_kwh = _ed_print_kwh(samples, base_w, nozzle_w, bed_w)
|
||||
if print_kwh is not None:
|
||||
parts_map = _ed_print_parts(samples, base_w, nozzle_w, bed_w)
|
||||
if parts_map is not None:
|
||||
source = "ed"
|
||||
|
||||
if print_kwh is None:
|
||||
if parts_map is None:
|
||||
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"
|
||||
|
||||
print_kwh = float(parts_map["print_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 {
|
||||
"kwh": kwh,
|
||||
"print_kwh": print_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,
|
||||
"euro_per_kwh": euro,
|
||||
"source": source,
|
||||
"parts": parts,
|
||||
}
|
||||
|
||||
|
||||
@@ -199,6 +237,7 @@ def order_actuals(conn, order_id):
|
||||
SELECT
|
||||
COALESCE(SUM(r.filament_cost + r.energy_cost), 0) AS cost_total,
|
||||
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.weight_g) FILTER (WHERE r.verdict = 'ausschuss'), 0) AS scrap_g,
|
||||
COUNT(*) FILTER (WHERE r.verdict = 'ausschuss') AS scrap_runs
|
||||
@@ -208,10 +247,19 @@ def order_actuals(conn, order_id):
|
||||
""",
|
||||
(order_id,),
|
||||
).fetchone()
|
||||
pieces_ok = int(row["pieces_ok"] or 0)
|
||||
cost_total = float(row["cost_total"] or 0)
|
||||
return {
|
||||
"cost_total": float(row["cost_total"] or 0),
|
||||
"cost_total": cost_total,
|
||||
"duration_s": float(row["duration_s"] or 0),
|
||||
"pieces_ok": pieces_ok,
|
||||
"pieces_bad": int(row["pieces_bad"] or 0),
|
||||
"scrap_g": float(row["scrap_g"] 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
|
||||
),
|
||||
}
|
||||
@@ -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
|
||||
@@ -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),
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import urllib.error
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
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.errors import GrottiError
|
||||
from grotti.parse import content_has_ace_trailer, prepare_rinkhals_gcode
|
||||
@@ -57,25 +57,47 @@ def unix_to_dt(value):
|
||||
|
||||
def choose_spool(conn, host, material, hinted_id, ended_at=None):
|
||||
"""Spule für History-Abzug. Ohne Hint nur Rollen, die vor Druckende existierten."""
|
||||
at_host = [
|
||||
row for row in store.spools_for_printer(conn, host)
|
||||
if _spool_valid_for_end(row, ended_at)
|
||||
]
|
||||
if hinted_id:
|
||||
row = store.get_spool_row(conn, hinted_id)
|
||||
if row and not _spool_valid_for_end(row, ended_at):
|
||||
return None
|
||||
# Hint nur nutzen, wenn die Rolle noch am Drucker liegt — sonst Rollenwechsel
|
||||
if row and (row.get("printer_host") or "") == (host or ""):
|
||||
return row
|
||||
if row and not at_host:
|
||||
# Rolle schon im Regal, kein Nachfolger am Host → Hint behalten (alte Jobs)
|
||||
return row
|
||||
if len(at_host) == 1:
|
||||
return at_host[0]
|
||||
if material:
|
||||
same = [row for row in at_host if (row["material_type"] or "").lower() == material.lower()]
|
||||
if len(same) == 1:
|
||||
return same[0]
|
||||
if hinted_id:
|
||||
row = store.get_spool_row(conn, hinted_id)
|
||||
if row and _spool_valid_for_end(row, ended_at):
|
||||
return row
|
||||
if row and not _spool_valid_for_end(row, ended_at):
|
||||
return None
|
||||
rows = [
|
||||
row for row in store.spools_for_printer(conn, host)
|
||||
if _spool_valid_for_end(row, ended_at)
|
||||
]
|
||||
if len(rows) == 1:
|
||||
return rows[0]
|
||||
if material:
|
||||
same = [row for row in rows if (row["material_type"] or "").lower() == material.lower()]
|
||||
if len(same) == 1:
|
||||
return same[0]
|
||||
return None
|
||||
|
||||
|
||||
def resolve_tracking_spool(conn, host, hinted_id, ace_map=None):
|
||||
"""Spule für Live-Tracking: am Host liegende Rolle schlägt veralteten Send-Hint."""
|
||||
current = resolve_spools_at_host(conn, host, ace_map=ace_map)
|
||||
if len(current) == 1:
|
||||
return current[0]["id"]
|
||||
if hinted_id:
|
||||
row = store.get_spool_row(conn, hinted_id)
|
||||
if row and (row.get("printer_host") or "") == (host or ""):
|
||||
return hinted_id
|
||||
if current:
|
||||
return current[0]["id"]
|
||||
return hinted_id
|
||||
|
||||
|
||||
def _spool_valid_for_end(spool, ended_at):
|
||||
"""Verhindert Abzug alter Moonraker-Jobs auf neu eingelegte Spulen."""
|
||||
if ended_at is None or not spool:
|
||||
@@ -127,21 +149,8 @@ def resolve_spools_at_host(conn, host, ace_map=None):
|
||||
|
||||
|
||||
def assert_material_match(file_row, spools):
|
||||
"""Datei-Material gegen eingelegte Rollen — warnt nicht bei leerer Liste."""
|
||||
want = {t.lower() for t in file_material_types(file_row)}
|
||||
if not want or not spools:
|
||||
"""Falsche Sorte bricht den Start nicht ab. Die Oberfläche warnt vorher."""
|
||||
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):
|
||||
@@ -557,12 +566,30 @@ def send_file(
|
||||
)
|
||||
# CJK/Unicode im Namen → Rinkhals 10115 not found (Upload ≠ Start-Name)
|
||||
filename = moonraker.safe_upload_name(filename)
|
||||
ace_tools = [item.get("paint_index") for item in mapping] if mapping else None
|
||||
if (row.get("kind") or "") == "gcode" and str(filename).endswith(".gcode"):
|
||||
if slots and mapping:
|
||||
# Immer an G-code-Tn ausrichten (auch wenn UI fälschlich 0/1 schickt)
|
||||
content, mapping, filaments, remap = acm.align_tools_for_ace(
|
||||
content, mapping, filaments
|
||||
)
|
||||
if remap:
|
||||
progress(
|
||||
"ace",
|
||||
32,
|
||||
"Slicer-T verdichtet (%s)…"
|
||||
% ", ".join("T%s→T%s" % pair for pair in sorted(remap.items())),
|
||||
)
|
||||
ace_tools = [item.get("paint_index") for item in mapping] if mapping else None
|
||||
content = prepare_rinkhals_gcode(content, tools=ace_tools)
|
||||
if keep_motors:
|
||||
# Slice-Ende `M84` → sofort unhomed; Abräumen braucht die Stepper.
|
||||
content = eject.strip_end_motor_disable(content)
|
||||
else:
|
||||
if slots and mapping:
|
||||
_, mapping, filaments, _ = acm.align_tools_for_ace(
|
||||
b"", mapping, filaments
|
||||
)
|
||||
ace_tools = [item.get("paint_index") for item in mapping] if mapping else None
|
||||
size_mb = len(content) / (1024 * 1024)
|
||||
|
||||
def upload_progress(sent, total):
|
||||
@@ -686,7 +713,7 @@ def steuern(conn, host, action, params=None):
|
||||
continue
|
||||
if not spool:
|
||||
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:
|
||||
raise GrottiError("Die Spule braucht eine Sorte.", "material")
|
||||
color = moonraker.spool_rgb(spool)
|
||||
|
||||
@@ -59,10 +59,55 @@ def sync_watch(conn, cfg, force=False):
|
||||
except (OSError, zipfile.BadZipFile):
|
||||
continue
|
||||
store.forget_missing_watch(conn, seen)
|
||||
link_watch_files_to_projects(conn, cfg)
|
||||
_LAST_WATCH_SYNC = now
|
||||
return {"gelesen": len(seen), "hinweis": ""}
|
||||
|
||||
|
||||
def _apply_meta(conn, file_id, row, meta):
|
||||
store.update_file(
|
||||
conn,
|
||||
file_id,
|
||||
{
|
||||
"printer_family": meta.get("printer_family") or row.get("printer_family"),
|
||||
"filaments": meta.get("filaments") or [],
|
||||
"weight_g": meta.get("weight_g") or 0,
|
||||
"estimated_s": meta.get("estimated_s") or 0,
|
||||
"thumb_entry": meta.get("thumb_entry") or "",
|
||||
"kind": meta.get("kind") or row.get("kind"),
|
||||
"plate": meta.get("plate") or "",
|
||||
"slice_meta": meta.get("slice_meta") or {},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def refresh_one_file(conn, file_id, watch_dir=None):
|
||||
"""Eine Datei neu parsen (paint_index / ACE). None wenn nicht gefunden."""
|
||||
row = store.get_file_row(conn, file_id)
|
||||
if not row:
|
||||
return None
|
||||
stored = row.get("stored_path") or ""
|
||||
path = Path(stored) if stored else None
|
||||
if path is not None and path.is_file():
|
||||
try:
|
||||
meta = meta_for(path)
|
||||
_apply_meta(conn, file_id, row, meta)
|
||||
row = store.get_file_row(conn, file_id) or row
|
||||
except (OSError, zipfile.BadZipFile):
|
||||
pass
|
||||
return store.file_out(row, watch_dir=watch_dir)
|
||||
|
||||
|
||||
def filaments_need_paint_index(filaments):
|
||||
rows = filaments if isinstance(filaments, list) else []
|
||||
if len(rows) < 2:
|
||||
return False
|
||||
for item in rows:
|
||||
if not isinstance(item, dict) or "paint_index" not in item:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def refresh_file_meta(conn, force=False):
|
||||
"""Metadaten bestehender Dateien neu lesen (z. B. paint_info → filaments)."""
|
||||
if not force:
|
||||
@@ -80,20 +125,7 @@ def refresh_file_meta(conn, force=False):
|
||||
meta = meta_for(path)
|
||||
except (OSError, zipfile.BadZipFile):
|
||||
continue
|
||||
store.update_file(
|
||||
conn,
|
||||
row["id"],
|
||||
{
|
||||
"printer_family": meta.get("printer_family") or row.get("printer_family"),
|
||||
"filaments": meta.get("filaments") or [],
|
||||
"weight_g": meta.get("weight_g") or 0,
|
||||
"estimated_s": meta.get("estimated_s") or 0,
|
||||
"thumb_entry": meta.get("thumb_entry") or "",
|
||||
"kind": meta.get("kind") or row.get("kind"),
|
||||
"plate": meta.get("plate") or "",
|
||||
"slice_meta": meta.get("slice_meta") or {},
|
||||
},
|
||||
)
|
||||
_apply_meta(conn, row["id"], row, meta)
|
||||
updated += 1
|
||||
return updated
|
||||
|
||||
@@ -213,27 +245,39 @@ def delete_folder(cfg, rel):
|
||||
return folder
|
||||
|
||||
|
||||
def _link_file_to_project_folder(conn, file_id, stored_path, watch_dir):
|
||||
"""Wenn Pfad unter einem Projekt-Ordner liegt → project_id setzen."""
|
||||
folder = store.file_folder(stored_path, "watch", watch_dir)
|
||||
if not folder:
|
||||
return
|
||||
for row in store.list_project_folders(conn):
|
||||
base = (row.get("folder") or "").strip().replace("\\", "/").strip("/")
|
||||
if not base:
|
||||
continue
|
||||
if folder == base or folder.startswith(base + "/"):
|
||||
store.set_file_project(conn, file_id, row["id"])
|
||||
return
|
||||
|
||||
|
||||
def move_file(conn, cfg, file_id, folder):
|
||||
"""Watch-Datei in Unterordner (oder Wurzel) verschieben; ID bleibt."""
|
||||
"""Watch- oder Upload-Datei in Slice-Unterordner (oder Wurzel); Upload wird Watch."""
|
||||
root = require_watch_dir(cfg)
|
||||
folder = normalize_folder(folder)
|
||||
row = store.get_file_row(conn, file_id)
|
||||
if not row:
|
||||
raise GrottiError("Die Datei fehlt.", "datei", 404)
|
||||
if row.get("source") != "watch":
|
||||
raise GrottiError(
|
||||
"Nur Dateien aus dem Slice-Ordner lassen sich verschieben.",
|
||||
"watch",
|
||||
)
|
||||
source = row.get("source") or ""
|
||||
if source not in ("watch", "upload"):
|
||||
raise GrottiError("Diese Datei lässt sich nicht verschieben.", "datei")
|
||||
src = Path(row.get("stored_path") or "")
|
||||
if not src.is_file():
|
||||
raise GrottiError("Die Datei liegt nicht mehr auf der Platte.", "datei", 404)
|
||||
dest_dir = path_under_watch(root, folder)
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
dest = dest_dir / src.name
|
||||
if dest.resolve() == src.resolve():
|
||||
if dest.resolve() == src.resolve() and source == "watch":
|
||||
return store.file_out(row, watch_dir=root)
|
||||
if dest.exists():
|
||||
if dest.exists() and dest.resolve() != src.resolve():
|
||||
dest = unique_path(dest_dir, src.name)
|
||||
try:
|
||||
shutil.move(str(src), str(dest))
|
||||
@@ -247,11 +291,204 @@ def move_file(conn, cfg, file_id, folder):
|
||||
name=dest.name,
|
||||
size_bytes=stat.st_size,
|
||||
mtime=stat.st_mtime,
|
||||
source="watch",
|
||||
)
|
||||
_link_file_to_project_folder(conn, file_id, str(dest.resolve()), root)
|
||||
row = store.get_file_row(conn, file_id)
|
||||
return store.file_out(row, watch_dir=root)
|
||||
|
||||
|
||||
def path_allowed_for_delete(stored_path, cfg):
|
||||
"""Nur Dateien unter Slice-Ordner oder data/eingang."""
|
||||
try:
|
||||
resolved = Path(stored_path).resolve()
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
roots = []
|
||||
watch = cfg.get("watch_dir")
|
||||
if watch:
|
||||
roots.append(Path(watch).resolve())
|
||||
data_dir = cfg.get("data_dir")
|
||||
if data_dir:
|
||||
roots.append((Path(data_dir) / "eingang").resolve())
|
||||
for root in roots:
|
||||
try:
|
||||
if resolved == root or root in resolved.parents:
|
||||
return True
|
||||
except OSError:
|
||||
continue
|
||||
return False
|
||||
|
||||
|
||||
def delete_library_file(conn, cfg, file_id):
|
||||
"""Bibliotheksdatei löschen (Upload oder Watch) — DB + Datei auf der Platte."""
|
||||
row = store.get_file_row(conn, file_id)
|
||||
if not row:
|
||||
raise GrottiError("Die Datei fehlt.", "datei", 404)
|
||||
stored = row.get("stored_path") or ""
|
||||
if stored and not path_allowed_for_delete(stored, cfg):
|
||||
raise GrottiError("Löschen außerhalb von Slice-Ordner oder Eingang ist nicht erlaubt.", "datei")
|
||||
store.delete_file(conn, file_id)
|
||||
if stored:
|
||||
Path(stored).unlink(missing_ok=True)
|
||||
return row
|
||||
|
||||
|
||||
_PRINT_SUFFIXES = (".gcode", ".3mf", ".gcode.3mf")
|
||||
|
||||
|
||||
def is_print_filename(name):
|
||||
low = (name or "").lower()
|
||||
return any(low.endswith(suf) for suf in _PRINT_SUFFIXES)
|
||||
|
||||
|
||||
def project_slug(name):
|
||||
"""Ordner-sichere Kurzform aus Projektnamen."""
|
||||
text = re.sub(r"[^\w\- äöüÄÖÜß.]+", "-", (name or "").strip(), flags=re.UNICODE)
|
||||
text = re.sub(r"\s+", "-", text)
|
||||
text = re.sub(r"-{2,}", "-", text).strip("-.")
|
||||
text = text[:60] or "projekt"
|
||||
return normalize_folder(text)
|
||||
|
||||
|
||||
def ensure_project_folder(cfg, folder_rel):
|
||||
"""Projektordner unter watch_dir anlegen; gibt normalisierten Relativpfad zurück."""
|
||||
root = require_watch_dir(cfg)
|
||||
folder = normalize_folder(folder_rel)
|
||||
if not folder:
|
||||
raise GrottiError("Projektordner fehlt.", "ordner")
|
||||
path_under_watch(root, folder).mkdir(parents=True, exist_ok=True)
|
||||
return folder
|
||||
|
||||
|
||||
def default_project_folder(name):
|
||||
return "Projekte/" + project_slug(name)
|
||||
|
||||
|
||||
def link_watch_files_to_projects(conn, cfg):
|
||||
"""Watch-Dateien in Projektordnern dem passenden Projekt zuordnen."""
|
||||
watch = cfg.get("watch_dir")
|
||||
if not watch:
|
||||
return 0
|
||||
projects = store.list_project_folders(conn)
|
||||
if not projects:
|
||||
return 0
|
||||
linked = 0
|
||||
for row in store.list_file_rows(conn):
|
||||
if (row.get("source") or "") != "watch":
|
||||
continue
|
||||
folder = store.file_folder(row.get("stored_path"), "watch", watch)
|
||||
if not folder:
|
||||
continue
|
||||
for proj in projects:
|
||||
base = (proj.get("folder") or "").strip().replace("\\", "/").strip("/")
|
||||
if not base:
|
||||
continue
|
||||
if folder == base or folder.startswith(base + "/"):
|
||||
if row.get("project_id") != proj["id"]:
|
||||
store.set_file_project(conn, row["id"], proj["id"])
|
||||
linked += 1
|
||||
break
|
||||
return linked
|
||||
|
||||
|
||||
def list_project_assets(cfg, folder_rel):
|
||||
"""Nicht-Druckdateien im Projektordner (flach + Unterordner)."""
|
||||
root = require_watch_dir(cfg)
|
||||
folder = normalize_folder(folder_rel)
|
||||
if not folder:
|
||||
return []
|
||||
base = path_under_watch(root, folder)
|
||||
if not base.is_dir():
|
||||
return []
|
||||
items = []
|
||||
for path in sorted(base.rglob("*")):
|
||||
if not path.is_file() or skip_path(path, cfg["data_dir"]):
|
||||
continue
|
||||
if is_print_filename(path.name):
|
||||
continue
|
||||
try:
|
||||
rel = path.resolve().relative_to(base).as_posix()
|
||||
except ValueError:
|
||||
continue
|
||||
stat = path.stat()
|
||||
items.append({
|
||||
"name": path.name,
|
||||
"rel": rel,
|
||||
"size_bytes": stat.st_size,
|
||||
"mtime": stat.st_mtime,
|
||||
})
|
||||
return items
|
||||
|
||||
|
||||
def resolve_project_asset(cfg, folder_rel, rel_name):
|
||||
"""Sicheren Pfad zu einer Asset-Datei im Projektordner."""
|
||||
root = require_watch_dir(cfg)
|
||||
folder = normalize_folder(folder_rel)
|
||||
if not folder:
|
||||
raise GrottiError("Projektordner fehlt.", "ordner")
|
||||
base = path_under_watch(root, folder)
|
||||
rel = str(rel_name or "").replace("\\", "/").strip("/")
|
||||
if not rel or ".." in rel.split("/"):
|
||||
raise GrottiError("Ungültiger Dateiname.", "datei")
|
||||
target = (base / rel).resolve()
|
||||
if base not in target.parents and target != base:
|
||||
raise GrottiError("Pfad außerhalb des Projektordners.", "ordner")
|
||||
if not target.is_file():
|
||||
raise GrottiError("Die Datei fehlt.", "datei", 404)
|
||||
return target
|
||||
|
||||
|
||||
def save_project_asset(cfg, folder_rel, filename, content):
|
||||
root = require_watch_dir(cfg)
|
||||
folder = normalize_folder(folder_rel)
|
||||
if not folder:
|
||||
raise GrottiError("Projektordner fehlt.", "ordner")
|
||||
dest_dir = path_under_watch(root, folder)
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
path = unique_path(dest_dir, filename)
|
||||
path.write_bytes(content)
|
||||
return {
|
||||
"name": path.name,
|
||||
"rel": path.name,
|
||||
"size_bytes": path.stat().st_size,
|
||||
"mtime": path.stat().st_mtime,
|
||||
}
|
||||
|
||||
|
||||
def delete_project_asset(cfg, folder_rel, rel_name):
|
||||
path = resolve_project_asset(cfg, folder_rel, rel_name)
|
||||
path.unlink(missing_ok=True)
|
||||
return True
|
||||
|
||||
|
||||
def delete_project_disk_folder(cfg, folder_rel, force=False):
|
||||
"""Projektordner auf der Platte entfernen (leer oder force inkl. Inhalt)."""
|
||||
if not folder_rel:
|
||||
return
|
||||
try:
|
||||
root = require_watch_dir(cfg)
|
||||
except GrottiError:
|
||||
return
|
||||
folder = normalize_folder(folder_rel)
|
||||
if not folder:
|
||||
return
|
||||
target = path_under_watch(root, folder)
|
||||
if not target.is_dir():
|
||||
return
|
||||
if force:
|
||||
shutil.rmtree(target, ignore_errors=True)
|
||||
return
|
||||
try:
|
||||
next(target.iterdir())
|
||||
raise GrottiError(
|
||||
"Projektordner ist nicht leer — mit Bestätigung inkl. Inhalt löschen.",
|
||||
"ordner",
|
||||
)
|
||||
except StopIteration:
|
||||
target.rmdir()
|
||||
|
||||
|
||||
def save_upload(conn, filename, content, cfg, project_id=None):
|
||||
directory = cfg["data_dir"] / "eingang"
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -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 '';
|
||||
@@ -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';
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Leere/alte Spulen aus der Alltagsliste nehmen
|
||||
ALTER TABLE spools ADD COLUMN IF NOT EXISTS archived_at timestamptz;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Projekt-Arbeitsmappe: Relativordner unter dem Slice-Pfad
|
||||
ALTER TABLE projects ADD COLUMN IF NOT EXISTS folder text NOT NULL DEFAULT '';
|
||||
@@ -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")
|
||||
|
||||
|
||||
_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):
|
||||
"""Klipper [exclude_object] — leer, wenn Modul fehlt oder Query scheitert."""
|
||||
empty = {"objects": [], "excluded": [], "current": None}
|
||||
@@ -1055,6 +1130,15 @@ def query(host):
|
||||
message = stats.get("message") or ""
|
||||
if (state or "").lower() == "error":
|
||||
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 {}
|
||||
axis_max = toolhead.get("axis_maximum") or []
|
||||
z_max = None
|
||||
@@ -1064,6 +1148,8 @@ def query(host):
|
||||
"ok": True,
|
||||
"state": state,
|
||||
"message": message,
|
||||
"filament_empty": filament_empty,
|
||||
"pause_reason": pause_reason or "",
|
||||
"homed_axes": toolhead.get("homed_axes") or "",
|
||||
"position": toolhead.get("position") or [],
|
||||
"z_max": z_max,
|
||||
@@ -1142,6 +1228,8 @@ def snapshot(printers):
|
||||
"ok": False,
|
||||
"state": "offline",
|
||||
"message": str(error),
|
||||
"filament_empty": False,
|
||||
"pause_reason": "",
|
||||
"camera": "",
|
||||
"has_camera": False,
|
||||
"fan": None,
|
||||
@@ -1185,20 +1273,23 @@ def camera_image(host):
|
||||
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):
|
||||
"""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)
|
||||
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:
|
||||
return {
|
||||
"camera": "/api/v1/kamera?host=%s" % quoted_host,
|
||||
"has_camera": True,
|
||||
}
|
||||
return {
|
||||
"camera": "/api/v1/kamera?host=%s" % quoted_host,
|
||||
"has_camera": None,
|
||||
}
|
||||
return {"camera": snap, "camera_stream": stream, "has_camera": True}
|
||||
return {"camera": snap, "camera_stream": stream, "has_camera": None}
|
||||
|
||||
|
||||
def embedded_bytes(host, name):
|
||||
|
||||
@@ -34,7 +34,12 @@ def send_telegram(cfg, text):
|
||||
return False
|
||||
|
||||
|
||||
ALERT_GROUP = {"abbruch": "fehler"}
|
||||
ALERT_GROUP = {
|
||||
"abbruch": "fehler",
|
||||
"bedarf": "queue",
|
||||
"wartung": "queue",
|
||||
"auftrag": "queue",
|
||||
}
|
||||
DEFAULT_OFF = {"ablauf", "start"}
|
||||
|
||||
|
||||
|
||||
@@ -91,40 +91,111 @@ def color_hex(channels):
|
||||
return "#%02x%02x%02x" % tuple(rgb)
|
||||
|
||||
|
||||
def profile_name(raw):
|
||||
"""Orca-Profil → Katalogname: `Sunlu PLA Meta @Kobra 3` → `Sunlu PLA Meta`."""
|
||||
text = (raw or "").strip().strip('"').strip("'").strip()
|
||||
if " @" in text:
|
||||
text = text.rsplit(" @", 1)[0].strip()
|
||||
return text
|
||||
|
||||
|
||||
def filament_profiles(comments):
|
||||
"""Profilnamen aus `; filament_settings_id`, Index = Slicer-Slot / paint_index."""
|
||||
raw = (comments or {}).get("filament_settings_id") or ""
|
||||
if not raw.strip():
|
||||
fallback = profile_name((comments or {}).get("default_filament_profile") or "")
|
||||
return [fallback] if fallback else []
|
||||
return [profile_name(part) for part in raw.split(";") if profile_name(part)]
|
||||
|
||||
|
||||
def attach_profiles(filaments, comments):
|
||||
"""Hängt `profile` (Sorte) an Filament-Einträge anhand des paint_index."""
|
||||
profiles = filament_profiles(comments)
|
||||
if not filaments:
|
||||
return filaments
|
||||
if not profiles:
|
||||
return filaments
|
||||
for index, row in enumerate(filaments):
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
try:
|
||||
paint = int(row.get("paint_index"))
|
||||
except (TypeError, ValueError):
|
||||
paint = index
|
||||
name = ""
|
||||
if 0 <= paint < len(profiles):
|
||||
name = profiles[paint]
|
||||
elif len(filaments) == 1 and len(profiles) == 1:
|
||||
name = profiles[0]
|
||||
if name:
|
||||
row["profile"] = name
|
||||
return filaments
|
||||
|
||||
|
||||
def filaments_from_paint_info(raw):
|
||||
"""Farben aus `; paint_info` — inkl. originalem `paint_index` (T-Nummer).
|
||||
|
||||
Bambu/Anycubic nutzen oft Lücken (T1 + T10 bei 16 Slots). Die Liste bleibt
|
||||
kompakt (nur belegte Farben), aber jeder Eintrag trägt den Slicer-Index,
|
||||
damit `.acm` und ACE-Map denselben Wert wie `Tn` im G-code schreiben.
|
||||
"""
|
||||
try:
|
||||
items = json.loads(raw or "")
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
return []
|
||||
if not isinstance(items, list):
|
||||
return []
|
||||
found = []
|
||||
found = {}
|
||||
next_fallback = 0
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
try:
|
||||
index = int(item.get("paint_index"))
|
||||
except (TypeError, ValueError):
|
||||
index = len(found)
|
||||
while len(found) <= index:
|
||||
found.append({"type": "", "color": ""})
|
||||
found[index] = {
|
||||
index = next_fallback
|
||||
if index < 0:
|
||||
continue
|
||||
next_fallback = max(next_fallback, index + 1)
|
||||
row = {
|
||||
"type": str(item.get("material_type") or "").strip(),
|
||||
"color": color_hex(item.get("paint_color")),
|
||||
"paint_index": index,
|
||||
}
|
||||
return [row for row in found if row["type"] or row["color"]]
|
||||
if row["type"] or row["color"]:
|
||||
found[index] = row
|
||||
return [found[key] for key in sorted(found)]
|
||||
|
||||
|
||||
def filaments_of(comments):
|
||||
painted = filaments_from_paint_info(comments.get("paint_info"))
|
||||
if painted:
|
||||
return painted
|
||||
return attach_profiles(painted, comments)
|
||||
types = [part.strip() for part in (comments.get("filament_type") or "").split(";") if part.strip()]
|
||||
colors = [part.strip() for part in (comments.get("filament_colour") or comments.get("filament_color") or "").split(";")]
|
||||
return [
|
||||
{"type": kind, "color": colors[index] if index < len(colors) else ""}
|
||||
rows = [
|
||||
{
|
||||
"type": kind,
|
||||
"color": colors[index] if index < len(colors) else "",
|
||||
"paint_index": index,
|
||||
}
|
||||
for index, kind in enumerate(types)
|
||||
]
|
||||
return attach_profiles(rows, comments)
|
||||
|
||||
|
||||
def tools_used_in_gcode(content):
|
||||
"""T-/MMU-Werkzeuge aus dem G-code (wie Rinkhals mmu_ace_metadata)."""
|
||||
if not content:
|
||||
return set()
|
||||
if isinstance(content, str):
|
||||
content = content.encode("utf-8", "replace")
|
||||
found = set()
|
||||
for line in _gcode_lines(content):
|
||||
match = _TOOL_RE.match(line)
|
||||
if match:
|
||||
found.add(int(match.group(1)))
|
||||
return found
|
||||
|
||||
|
||||
def _gcode_lines(content):
|
||||
|
||||
@@ -10,7 +10,7 @@ import threading
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from grotti import eject, jobs, moonraker, notify, store
|
||||
from grotti import acm, eject, jobs, moonraker, notify, store
|
||||
from grotti.db import connect
|
||||
from grotti.errors import GrottiError
|
||||
from grotti import time_window
|
||||
@@ -144,14 +144,46 @@ class PrintQueueDispatcher:
|
||||
return (datetime.now(timezone.utc) - updated).total_seconds()
|
||||
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):
|
||||
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(
|
||||
conn,
|
||||
item["id"],
|
||||
{
|
||||
"status": "error",
|
||||
"message": "Start abgebrochen (Neustart)",
|
||||
"message": msg,
|
||||
"phase": "",
|
||||
"phase_started_at": None,
|
||||
},
|
||||
@@ -274,6 +306,11 @@ class PrintQueueDispatcher:
|
||||
if (waiting.get("message") or "") != WINDOW_WAIT_MSG:
|
||||
store.update_print_queue(conn, waiting["id"], {"message": WINDOW_WAIT_MSG})
|
||||
continue
|
||||
# Nächster passender / leere Map: ACE aus Spulen bauen oder warten
|
||||
if not _has_ablauf(waiting):
|
||||
waiting = self._ensure_ace_map(conn, waiting, host, live)
|
||||
if not waiting:
|
||||
continue
|
||||
claimed = store.claim_print_queue(conn, waiting["id"], host)
|
||||
if not claimed:
|
||||
continue
|
||||
@@ -285,6 +322,49 @@ class PrintQueueDispatcher:
|
||||
self._start_simple(conn, claimed, printer)
|
||||
conn.commit()
|
||||
|
||||
def _ensure_ace_map(self, conn, waiting, host, live):
|
||||
"""Bei ACE-Drucker und leerer Map: aus Spulen zuordnen oder Wartegrund setzen."""
|
||||
slots = (live or {}).get("slots") or []
|
||||
if not slots:
|
||||
return waiting
|
||||
ace = waiting.get("ace_map") or []
|
||||
if isinstance(ace, str):
|
||||
import json
|
||||
|
||||
try:
|
||||
ace = json.loads(ace)
|
||||
except json.JSONDecodeError:
|
||||
ace = []
|
||||
if acm.normalize_mapping(ace if isinstance(ace, list) else []):
|
||||
return waiting
|
||||
file_id = waiting.get("file_id")
|
||||
if not file_id:
|
||||
return waiting
|
||||
mapping, reason = store.auto_ace_map(conn, host, file_id)
|
||||
if reason:
|
||||
if (waiting.get("wait_reason") or "") != reason or (waiting.get("message") or "") != reason:
|
||||
store.update_print_queue(
|
||||
conn,
|
||||
waiting["id"],
|
||||
{"wait_reason": reason, "message": reason},
|
||||
)
|
||||
return None
|
||||
if not mapping:
|
||||
# ACE am Drucker, aber keine ACE-Spulen in Grotti → nicht raten
|
||||
reason = "ACE-Zuordnung fehlt (keine passende Spule)"
|
||||
if (waiting.get("wait_reason") or "") != reason:
|
||||
store.update_print_queue(
|
||||
conn,
|
||||
waiting["id"],
|
||||
{"wait_reason": reason, "message": reason},
|
||||
)
|
||||
return None
|
||||
store.update_print_queue(conn, waiting["id"], {"ace_map": mapping, "wait_reason": "", "message": ""})
|
||||
waiting = dict(waiting)
|
||||
waiting["ace_map"] = mapping
|
||||
waiting["wait_reason"] = ""
|
||||
return waiting
|
||||
|
||||
def _start_simple(self, conn, item, printer):
|
||||
"""Upload/Start im Worker — mehrere freie Drucker parallel."""
|
||||
host = printer["host"]
|
||||
@@ -828,43 +908,64 @@ class PrintQueueDispatcher:
|
||||
return state
|
||||
|
||||
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"]
|
||||
cool = eject.normalize_cool(step)
|
||||
item_id = item["id"]
|
||||
printer_copy = dict(printer)
|
||||
item_copy = dict(item)
|
||||
|
||||
def work():
|
||||
with connect(self.database_url) as wconn:
|
||||
try:
|
||||
moonraker.gcode_script(host, eject.cool_script(cool), timeout=30)
|
||||
# Idle zuerst verlängern, dann Park (sonst Stepper aus → must home).
|
||||
try:
|
||||
moonraker.gcode_script(host, eject.idle_timeout_script(cool), timeout=15)
|
||||
except Exception:
|
||||
pass
|
||||
park = self._try_cool_park(host, item["id"], force=True)
|
||||
self._keepalive[item["id"]] = time.monotonic()
|
||||
park = self._try_cool_park(host, item_id, force=True)
|
||||
self._keepalive[item_id] = time.monotonic()
|
||||
park_note = ""
|
||||
if not park["ok"] and park["error"]:
|
||||
park_note = " — Mitte fehlgeschlagen: %s" % park["error"]
|
||||
elif park["ok"]:
|
||||
park_note = " · Düse Mitte"
|
||||
row = store.get_print_queue_row(wconn, item_id) or item_copy
|
||||
store.update_print_queue(
|
||||
wconn,
|
||||
item_id,
|
||||
{
|
||||
"message": self._ablauf_message(row, "kühlt%s" % park_note),
|
||||
"phase": "cooling",
|
||||
"phase_started_at": datetime.now(timezone.utc),
|
||||
},
|
||||
)
|
||||
wconn.commit()
|
||||
except GrottiError as exc:
|
||||
self._fail_ablauf(wconn, item_copy, printer_copy, "Abkühlen: %s" % exc.message)
|
||||
wconn.commit()
|
||||
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"],
|
||||
item_id,
|
||||
{
|
||||
"phase": "cooling",
|
||||
"phase_started_at": "now",
|
||||
"message": self._ablauf_message(
|
||||
item,
|
||||
"kühlt ab (Bett ≤ %.0f °C, Düse ≤ %.0f °C)%s"
|
||||
% (cool["bed_max"], cool["nozzle_max"], park_note),
|
||||
"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%s"
|
||||
% (cool["bed_max"], cool["nozzle_max"], park_note),
|
||||
"kühlt ab bis Bett ≤ %.0f °C, Düse ≤ %.0f °C" % (cool["bed_max"], cool["nozzle_max"]),
|
||||
)
|
||||
except Exception as exc:
|
||||
self._fail_ablauf(conn, item, printer, "Abkühlen: %s" % exc)
|
||||
self._spawn_worker(item_id, work)
|
||||
|
||||
def _poll_cool(self, conn, item, printer, live, step):
|
||||
cool = eject.normalize_cool(step)
|
||||
@@ -1214,6 +1315,16 @@ class PrintQueueDispatcher:
|
||||
self._fail_ablauf(conn, item, printer, "Unbekannter Schritt: %s" % kind)
|
||||
|
||||
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(
|
||||
conn,
|
||||
item["id"],
|
||||
|
||||
@@ -7,9 +7,12 @@ CREATE TABLE IF NOT EXISTS projects (
|
||||
notes text NOT NULL DEFAULT '',
|
||||
status text NOT NULL DEFAULT 'idee',
|
||||
quote jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
folder text NOT NULL DEFAULT '',
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
ALTER TABLE projects ADD COLUMN IF NOT EXISTS folder text NOT NULL DEFAULT '';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS spools (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
name text NOT NULL,
|
||||
@@ -123,6 +126,7 @@ ALTER TABLE spools ADD COLUMN IF NOT EXISTS material_id bigint REFERENCES materi
|
||||
ALTER TABLE spools ADD COLUMN IF NOT EXISTS chargen_nr text;
|
||||
ALTER TABLE spools ADD COLUMN IF NOT EXISTS hersteller_charge text NOT NULL DEFAULT '';
|
||||
ALTER TABLE spools ADD COLUMN IF NOT EXISTS code text;
|
||||
ALTER TABLE spools ADD COLUMN IF NOT EXISTS archived_at timestamptz;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS spools_chargen_nr ON spools (chargen_nr) WHERE chargen_nr IS NOT NULL AND chargen_nr <> '';
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS spools_code ON spools (code) WHERE code IS NOT NULL AND code <> '';
|
||||
@@ -172,6 +176,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 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 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_running_host ON print_queue (host, status) WHERE status = 'running';
|
||||
|
||||
@@ -20,6 +20,7 @@ def project_out(row, files=None, file_count=None):
|
||||
"notes": row["notes"],
|
||||
"status": row["status"],
|
||||
"quote": row["quote"] or {},
|
||||
"folder": (row.get("folder") or "").strip(),
|
||||
"created_at": iso(row["created_at"]),
|
||||
}
|
||||
if files is not None:
|
||||
@@ -76,6 +77,7 @@ def spool_out(row):
|
||||
colors = row.get("colors") or []
|
||||
if isinstance(colors, str):
|
||||
colors = json.loads(colors)
|
||||
archived = row.get("archived_at")
|
||||
return {
|
||||
"id": row["id"],
|
||||
"name": row["name"],
|
||||
@@ -93,6 +95,8 @@ def spool_out(row):
|
||||
"chargen_nr": row.get("chargen_nr") or "",
|
||||
"hersteller_charge": row.get("hersteller_charge") or "",
|
||||
"code": row.get("code") or "",
|
||||
"archived_at": archived.isoformat() if archived and hasattr(archived, "isoformat") else (archived or None),
|
||||
"archived": bool(archived),
|
||||
}
|
||||
|
||||
|
||||
@@ -176,6 +180,12 @@ def run_out(row):
|
||||
"chargen_nr": row.get("chargen_nr") or "",
|
||||
"message": row.get("message") or "",
|
||||
"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
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -203,14 +213,14 @@ def get_project(conn, project_id):
|
||||
return project_out(row, files=[file_out(item) for item in files])
|
||||
|
||||
|
||||
def create_project(conn, name, notes, status):
|
||||
def create_project(conn, name, notes, status, folder=""):
|
||||
row = conn.execute(
|
||||
"""
|
||||
INSERT INTO projects (name, notes, status)
|
||||
VALUES (%s, %s, %s)
|
||||
INSERT INTO projects (name, notes, status, folder)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
RETURNING *
|
||||
""",
|
||||
(name, notes, status),
|
||||
(name, notes, status, (folder or "").strip()),
|
||||
).fetchone()
|
||||
return project_out(row, files=[], file_count=0)
|
||||
|
||||
@@ -218,10 +228,10 @@ def create_project(conn, name, notes, status):
|
||||
def update_project(conn, project_id, fields):
|
||||
sets = []
|
||||
values = []
|
||||
for key in ("name", "notes", "status"):
|
||||
for key in ("name", "notes", "status", "folder"):
|
||||
if key in fields:
|
||||
sets.append("%s = %%s" % key)
|
||||
values.append(fields[key])
|
||||
values.append(fields[key] if key != "folder" else (fields[key] or "").strip())
|
||||
if "quote" in fields:
|
||||
sets.append("quote = %s")
|
||||
values.append(Json(fields["quote"]))
|
||||
@@ -237,6 +247,24 @@ def update_project(conn, project_id, fields):
|
||||
return get_project(conn, project_id)
|
||||
|
||||
|
||||
def list_project_folders(conn):
|
||||
"""id + folder für Sync (nur Projekte mit Ordner)."""
|
||||
return conn.execute(
|
||||
"""
|
||||
SELECT id, folder FROM projects
|
||||
WHERE coalesce(folder, '') <> ''
|
||||
ORDER BY length(folder) DESC, folder
|
||||
"""
|
||||
).fetchall()
|
||||
|
||||
|
||||
def set_file_project(conn, file_id, project_id):
|
||||
conn.execute(
|
||||
"UPDATE files SET project_id = %s WHERE id = %s",
|
||||
(project_id, file_id),
|
||||
)
|
||||
|
||||
|
||||
def delete_project(conn, project_id):
|
||||
row = conn.execute("DELETE FROM projects WHERE id = %s RETURNING id", (project_id,)).fetchone()
|
||||
return bool(row)
|
||||
@@ -335,7 +363,7 @@ def update_file(conn, file_id, fields, watch_dir=None):
|
||||
return file_out(row, watch_dir=watch_dir) if row else None
|
||||
|
||||
|
||||
def update_file_location(conn, file_id, stored_path, name=None, size_bytes=None, mtime=None):
|
||||
def update_file_location(conn, file_id, stored_path, name=None, size_bytes=None, mtime=None, source=None):
|
||||
"""Pfad nach Verschieben auf der Platte — ID und Zuordnungen bleiben."""
|
||||
sets = ["stored_path = %s"]
|
||||
values = [stored_path]
|
||||
@@ -348,6 +376,9 @@ def update_file_location(conn, file_id, stored_path, name=None, size_bytes=None,
|
||||
if mtime is not None:
|
||||
sets.append("mtime = %s")
|
||||
values.append(mtime)
|
||||
if source is not None:
|
||||
sets.append("source = %s")
|
||||
values.append(source)
|
||||
values.append(file_id)
|
||||
return conn.execute(
|
||||
"UPDATE files SET %s WHERE id = %%s RETURNING *" % ", ".join(sets),
|
||||
@@ -434,6 +465,14 @@ def update_spool(conn, spool_id, fields):
|
||||
if key in fields:
|
||||
sets.append("%s = %%s" % key)
|
||||
values.append(Json(fields[key]) if key == "colors" else fields[key])
|
||||
if "archived_at" in fields:
|
||||
if fields["archived_at"] is None or fields["archived_at"] is False:
|
||||
sets.append("archived_at = NULL")
|
||||
elif fields["archived_at"] is True or fields["archived_at"] == "now":
|
||||
sets.append("archived_at = now()")
|
||||
else:
|
||||
sets.append("archived_at = %s")
|
||||
values.append(fields["archived_at"])
|
||||
if not sets:
|
||||
row = get_spool_row(conn, spool_id)
|
||||
return spool_out(row) if row else None
|
||||
@@ -445,6 +484,23 @@ def update_spool(conn, spool_id, fields):
|
||||
return spool_out(row) if row else None
|
||||
|
||||
|
||||
def mark_spool_empty(conn, spool_id):
|
||||
"""Rest auf 0, vom Drucker ins Regal, archivieren."""
|
||||
row = conn.execute(
|
||||
"""
|
||||
UPDATE spools
|
||||
SET remaining_g = 0,
|
||||
printer_host = '',
|
||||
ace_slot = NULL,
|
||||
archived_at = COALESCE(archived_at, now())
|
||||
WHERE id = %s
|
||||
RETURNING *
|
||||
""",
|
||||
(spool_id,),
|
||||
).fetchone()
|
||||
return spool_out(row) if row else None
|
||||
|
||||
|
||||
def material_out(row):
|
||||
s1 = row["volumetric_s1"]
|
||||
return {
|
||||
@@ -1272,6 +1328,27 @@ def find_live_run(conn, host, filename=""):
|
||||
).fetchone()
|
||||
|
||||
|
||||
def find_active_run(conn, host, filename=""):
|
||||
"""Offener Lauf am Host: Live bevorzugt, sonst offener History-Job (Pause/Resume)."""
|
||||
return find_live_run(conn, host, filename) or find_open_history_run(conn, host, filename)
|
||||
|
||||
|
||||
def update_send_spool(conn, host, filename, spool_id):
|
||||
"""Spule am letzten Send dieser Datei korrigieren (Rollenwechsel während Druck)."""
|
||||
host = (host or "").strip()
|
||||
if not host or not spool_id:
|
||||
return None
|
||||
sent = latest_send(conn, host, filename or "")
|
||||
if not sent:
|
||||
return None
|
||||
if sent.get("spool_id") == spool_id:
|
||||
return sent
|
||||
return conn.execute(
|
||||
"UPDATE sends SET spool_id = %s WHERE id = %s RETURNING *",
|
||||
(spool_id, sent["id"]),
|
||||
).fetchone()
|
||||
|
||||
|
||||
def ensure_live_run(conn, host, printer_name, filename, spool_id=None):
|
||||
"""Aktiven Lauf: History-Job bevorzugen, sonst Live — nie parallel beide offen."""
|
||||
hist = find_open_history_run(conn, host, filename)
|
||||
@@ -1280,7 +1357,7 @@ def ensure_live_run(conn, host, printer_name, filename, spool_id=None):
|
||||
if live:
|
||||
adopt_live_run(conn, host, filename or hist.get("filename") or "", hist["id"])
|
||||
hist = get_run_row(conn, hist["id"]) or hist
|
||||
if spool_id and not hist.get("spool_id"):
|
||||
if spool_id and hist.get("spool_id") != spool_id:
|
||||
conn.execute("UPDATE runs SET spool_id = %s WHERE id = %s", (spool_id, hist["id"]))
|
||||
hist = get_run_row(conn, hist["id"]) or hist
|
||||
return hist
|
||||
@@ -1292,6 +1369,9 @@ def ensure_live_run(conn, host, printer_name, filename, spool_id=None):
|
||||
(filename, row["id"]),
|
||||
)
|
||||
row = get_run_row(conn, row["id"])
|
||||
if spool_id and row and row.get("spool_id") != spool_id:
|
||||
conn.execute("UPDATE runs SET spool_id = %s WHERE id = %s", (spool_id, row["id"]))
|
||||
row = get_run_row(conn, row["id"])
|
||||
return row
|
||||
job_id = "live:%s:%s" % (host, datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S"))
|
||||
return upsert_run(conn, {
|
||||
@@ -1343,6 +1423,30 @@ def abandon_live_runs(conn, host, keep_id=None):
|
||||
return len(rows)
|
||||
|
||||
|
||||
def complete_open_run(conn, run_id, state="completed"):
|
||||
"""Offenen Lauf als beendet markieren (Abräumen / Druckende ohne History)."""
|
||||
state = (state or "completed").strip() or "completed"
|
||||
if state not in ("completed", "cancelled", "error", "interrupted"):
|
||||
state = "completed"
|
||||
return conn.execute(
|
||||
"""
|
||||
UPDATE runs
|
||||
SET state = %s,
|
||||
ended_at = COALESCE(ended_at, now()),
|
||||
duration_s = CASE
|
||||
WHEN COALESCE(duration_s, 0) > 0 THEN duration_s
|
||||
WHEN started_at IS NOT NULL
|
||||
THEN EXTRACT(EPOCH FROM (now() - started_at))
|
||||
ELSE COALESCE(duration_s, 0)
|
||||
END
|
||||
WHERE id = %s
|
||||
AND state IN ('in_progress', 'printing', 'paused')
|
||||
RETURNING *
|
||||
""",
|
||||
(state, run_id),
|
||||
).fetchone()
|
||||
|
||||
|
||||
def adopt_live_run(conn, host, filename, real_run_id):
|
||||
"""Proben/Segmente vom Live-Lauf auf den History-Lauf umhängen."""
|
||||
live = find_live_run(conn, host, filename)
|
||||
@@ -1766,7 +1870,7 @@ def exclusive_until(until):
|
||||
return until
|
||||
|
||||
|
||||
def statistics(conn, since=None, until=None, host="", material="", brand="", project_id=None):
|
||||
def statistics(conn, since=None, until=None, host="", material="", brand="", color="", project_id=None):
|
||||
where = ["r.state IN ('completed', 'cancelled', 'error', 'interrupted')"]
|
||||
values = []
|
||||
until_bound = exclusive_until(until)
|
||||
@@ -1784,7 +1888,7 @@ def statistics(conn, since=None, until=None, host="", material="", brand="", pro
|
||||
values.append(project_id)
|
||||
|
||||
spool_join = ""
|
||||
if material or brand:
|
||||
if material or brand or color:
|
||||
spool_join = (
|
||||
"JOIN run_spools rs0 ON rs0.run_id = r.id "
|
||||
"JOIN spools s0 ON s0.id = rs0.spool_id"
|
||||
@@ -1795,6 +1899,9 @@ def statistics(conn, since=None, until=None, host="", material="", brand="", pro
|
||||
if brand:
|
||||
where.append("s0.brand ILIKE %s")
|
||||
values.append(brand)
|
||||
if color:
|
||||
where.append("s0.color ILIKE %s")
|
||||
values.append(color)
|
||||
clause = " AND ".join(where)
|
||||
|
||||
overview = conn.execute(
|
||||
@@ -1833,6 +1940,16 @@ def statistics(conn, since=None, until=None, host="", material="", brand="", pro
|
||||
run_where.append("r.project_id = %s")
|
||||
run_values.append(project_id)
|
||||
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(
|
||||
"""
|
||||
@@ -1858,11 +1975,14 @@ def statistics(conn, since=None, until=None, host="", material="", brand="", pro
|
||||
if brand:
|
||||
mat_where.append("s.brand ILIKE %s")
|
||||
mat_values.append(brand)
|
||||
if color:
|
||||
mat_where.append("s.color ILIKE %s")
|
||||
mat_values.append(color)
|
||||
mat_clause = " AND ".join(mat_where)
|
||||
|
||||
by_material = conn.execute(
|
||||
"""
|
||||
SELECT s.material_type, s.brand,
|
||||
SELECT s.material_type, s.brand, s.color,
|
||||
COALESCE(SUM(rs.weight_g), 0) AS weight_g,
|
||||
COALESCE(SUM(rs.cost), 0) AS cost,
|
||||
COUNT(DISTINCT rs.run_id) AS jobs
|
||||
@@ -1870,7 +1990,7 @@ def statistics(conn, since=None, until=None, host="", material="", brand="", pro
|
||||
JOIN runs r ON r.id = rs.run_id
|
||||
JOIN spools s ON s.id = rs.spool_id
|
||||
WHERE %s
|
||||
GROUP BY s.material_type, s.brand
|
||||
GROUP BY s.material_type, s.brand, s.color
|
||||
ORDER BY weight_g DESC
|
||||
""" % mat_clause,
|
||||
mat_values,
|
||||
@@ -1904,6 +2024,17 @@ def statistics(conn, since=None, until=None, host="", material="", brand="", pro
|
||||
ov = overview or {}
|
||||
# Bei Spulen-Join können SUM(DISTINCT …) ungenau sein — Überblick ohne Join neu
|
||||
if spool_join:
|
||||
spool_filters = []
|
||||
spool_vals = list(run_values)
|
||||
if material:
|
||||
spool_filters.append("AND s.material_type ILIKE %s")
|
||||
spool_vals.append(material)
|
||||
if brand:
|
||||
spool_filters.append("AND s.brand ILIKE %s")
|
||||
spool_vals.append(brand)
|
||||
if color:
|
||||
spool_filters.append("AND s.color ILIKE %s")
|
||||
spool_vals.append(color)
|
||||
overview2 = conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
@@ -1924,18 +2055,16 @@ def statistics(conn, since=None, until=None, host="", material="", brand="", pro
|
||||
JOIN spools s ON s.id = rs.spool_id
|
||||
WHERE rs.run_id = r.id
|
||||
%s
|
||||
%s
|
||||
)
|
||||
)
|
||||
""" % (
|
||||
run_clause,
|
||||
("AND s.material_type ILIKE %%s" if material else ""),
|
||||
("AND s.brand ILIKE %%s" if brand else ""),
|
||||
),
|
||||
run_values + ([material] if material else []) + ([brand] if brand else []),
|
||||
""" % (run_clause, " ".join(spool_filters)),
|
||||
spool_vals,
|
||||
).fetchone()
|
||||
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 {
|
||||
"overview": {
|
||||
"jobs": int(ov.get("jobs") or 0),
|
||||
@@ -1946,6 +2075,10 @@ def statistics(conn, since=None, until=None, host="", material="", brand="", pro
|
||||
"failed": int(ov.get("failed") or 0),
|
||||
"scrap": int(ov.get("scrap") 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": [
|
||||
{
|
||||
@@ -1963,6 +2096,7 @@ def statistics(conn, since=None, until=None, host="", material="", brand="", pro
|
||||
{
|
||||
"material_type": row["material_type"] or "",
|
||||
"brand": row["brand"] or "",
|
||||
"color": row.get("color") or "",
|
||||
"weight_g": float(row["weight_g"] or 0),
|
||||
"cost": float(row["cost"] or 0),
|
||||
"jobs": int(row["jobs"] or 0),
|
||||
@@ -2081,20 +2215,28 @@ def create_printer(conn, printer_id, name, host, family):
|
||||
return printer_out(row)
|
||||
|
||||
|
||||
def update_printer(conn, printer_id, name, host, family, locked=None):
|
||||
def update_printer(conn, printer_id, name, host, family, locked=None, start_window=None):
|
||||
current = printer_by_id(conn, printer_id)
|
||||
if not current:
|
||||
return None
|
||||
if locked is None:
|
||||
locked = bool(current.get("locked"))
|
||||
if start_window is None:
|
||||
sw = current.get("start_window") or {}
|
||||
elif isinstance(start_window, dict):
|
||||
sw = start_window
|
||||
else:
|
||||
sw = {}
|
||||
if not isinstance(sw, dict):
|
||||
sw = {}
|
||||
row = conn.execute(
|
||||
"""
|
||||
UPDATE printers
|
||||
SET name = %s, host = %s, family = %s, locked = %s
|
||||
SET name = %s, host = %s, family = %s, locked = %s, start_window = %s
|
||||
WHERE id = %s
|
||||
RETURNING *
|
||||
""",
|
||||
(name, host, family, bool(locked), printer_id),
|
||||
(name, host, family, bool(locked), Json(sw), printer_id),
|
||||
).fetchone()
|
||||
if current["host"] != host:
|
||||
old = current["host"]
|
||||
@@ -2178,7 +2320,11 @@ def event_out(row):
|
||||
|
||||
|
||||
# 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):
|
||||
@@ -2224,19 +2370,29 @@ def list_events(conn, limit=40, unread_only=False, host=None, kinds=None, since=
|
||||
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"]
|
||||
|
||||
|
||||
def mark_events_read(conn, event_ids=None):
|
||||
def mark_events_read(conn, event_ids=None, kinds=None):
|
||||
if event_ids:
|
||||
conn.execute(
|
||||
"UPDATE events SET read_at = now() WHERE read_at IS NULL AND id = ANY(%s)",
|
||||
(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:
|
||||
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):
|
||||
@@ -2315,6 +2471,8 @@ def print_queue_out(row, file_name=None):
|
||||
"phase_started_at": iso(row.get("phase_started_at")),
|
||||
"is_ablauf": bool(steps),
|
||||
"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"]),
|
||||
"updated_at": iso(row["updated_at"]),
|
||||
}
|
||||
@@ -2490,6 +2648,8 @@ def update_print_queue(conn, item_id, fields):
|
||||
"spool_id": "spool_id",
|
||||
"bed_leveling": "bed_leveling",
|
||||
"override_machine": "override_machine",
|
||||
"retry_count": "retry_count",
|
||||
"wait_reason": "wait_reason",
|
||||
}
|
||||
sets = []
|
||||
values = []
|
||||
@@ -2530,32 +2690,291 @@ def update_print_queue(conn, item_id, fields):
|
||||
return print_queue_out(fresh, fresh.get("filename")) if fresh else None
|
||||
|
||||
|
||||
def next_waiting_print(conn, host=None, family=None):
|
||||
if host:
|
||||
row = conn.execute(
|
||||
def _profile_matches(need_profile, spool_name):
|
||||
"""Sorte: exakter Katalogname (Meta ≠ Matte ≠ PLA). Zusatz nur hinter dem Namen."""
|
||||
a = (need_profile or "").strip().lower()
|
||||
b = (spool_name or "").strip().lower()
|
||||
if not a:
|
||||
return True
|
||||
if not b:
|
||||
return False
|
||||
if a == b:
|
||||
return True
|
||||
# Spulen-Anzeigename: "Sunlu PLA Meta schwarz" — nicht "Sunlu PLA" ⊂ "Sunlu PLA Matte"
|
||||
if b.startswith(a) and len(b) > len(a) and not b[len(a)].isalnum():
|
||||
rest = b[len(a):].lstrip(" ·-–_|/")
|
||||
# Nächstes Wort darf keine bekannte PLA-/PETG-Variante sein
|
||||
first = (rest.split() or [""])[0]
|
||||
if first in {"meta", "matte", "matt", "silk", "plus", "+", "hf", "cf"}:
|
||||
return False
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _need_label(need):
|
||||
profile = (need.get("profile") or "").strip()
|
||||
if profile:
|
||||
return profile
|
||||
kind = (need.get("material_type") or "").strip()
|
||||
color = (need.get("color") or "").strip()
|
||||
return (kind + ((" " + color) if color else "")).strip() or "Material"
|
||||
|
||||
|
||||
def _spool_material_set(conn, host):
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT q.*, f.name AS filename
|
||||
SELECT lower(coalesce(s.material_type, '')) AS mt,
|
||||
lower(coalesce(s.color, '')) AS color,
|
||||
coalesce(s.remaining_g, 0) AS remaining_g,
|
||||
lower(coalesce(m.name, s.name, '')) AS material_name,
|
||||
s.ace_slot AS ace_slot,
|
||||
s.id AS spool_id
|
||||
FROM spools s
|
||||
LEFT JOIN materials m ON m.id = s.material_id
|
||||
WHERE s.printer_host = %s
|
||||
AND s.archived_at IS NULL
|
||||
AND coalesce(s.remaining_g, 0) > 0.5
|
||||
""",
|
||||
(host or "",),
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"material_type": (r["mt"] or "").strip(),
|
||||
"color": (r["color"] or "").strip(),
|
||||
"remaining_g": float(r["remaining_g"] or 0),
|
||||
"material_name": (r["material_name"] or "").strip(),
|
||||
"ace_slot": r["ace_slot"],
|
||||
"spool_id": r["spool_id"],
|
||||
}
|
||||
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 index, item in enumerate(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()
|
||||
profile = (item.get("profile") or "").strip()
|
||||
try:
|
||||
paint = int(item.get("paint_index"))
|
||||
except (TypeError, ValueError):
|
||||
paint = index
|
||||
if t or profile:
|
||||
types.append({
|
||||
"material_type": t,
|
||||
"color": c,
|
||||
"profile": profile,
|
||||
"paint_index": paint,
|
||||
})
|
||||
elif isinstance(item, str) and item.strip():
|
||||
types.append({
|
||||
"material_type": item.strip().lower(),
|
||||
"color": "",
|
||||
"profile": "",
|
||||
"paint_index": index,
|
||||
})
|
||||
return types, float(row.get("weight_g") or 0)
|
||||
|
||||
|
||||
def _match_spools_for_need(need, have, used_ids=None):
|
||||
used = used_ids or set()
|
||||
candidates = [h for h in have if h.get("spool_id") not in used]
|
||||
profile = (need.get("profile") or "").strip()
|
||||
if profile:
|
||||
by_profile = [h for h in candidates if _profile_matches(profile, h.get("material_name"))]
|
||||
if not by_profile:
|
||||
return []
|
||||
colored = [
|
||||
h for h in by_profile
|
||||
if not need.get("color") or h["color"] == need["color"] or not h["color"]
|
||||
]
|
||||
return colored or by_profile
|
||||
kind = need.get("material_type") or ""
|
||||
return [
|
||||
h for h in candidates
|
||||
if (not kind or h["material_type"] == kind)
|
||||
and (not need.get("color") or h["color"] == need["color"] or not h["color"])
|
||||
]
|
||||
|
||||
|
||||
def material_fit_score(conn, host, file_id):
|
||||
"""Höher = besser. -1 = warten (falsche Sorte mit Profil, oder zu wenig Rest)."""
|
||||
need, weight = _file_needs(conn, file_id)
|
||||
have = _spool_material_set(conn, host)
|
||||
if not need:
|
||||
return 0, ""
|
||||
if not have:
|
||||
# Mit Profil: ohne Spule warten. Ohne Profil: soft (wie bisher).
|
||||
if any((n.get("profile") or "").strip() for n in need):
|
||||
return -1, "braucht %s" % _need_label(need[0])
|
||||
return 0, ""
|
||||
|
||||
has_profiles = any((n.get("profile") or "").strip() for n in need)
|
||||
if has_profiles:
|
||||
score = 20
|
||||
used = set()
|
||||
for n in need:
|
||||
matched = _match_spools_for_need(n, have, used)
|
||||
if not matched:
|
||||
return -1, "braucht %s" % _need_label(n)
|
||||
best = max(matched, key=lambda h: h["remaining_g"])
|
||||
used.add(best.get("spool_id"))
|
||||
if n.get("color") and any(h["color"] == n["color"] for h in matched):
|
||||
score += 5
|
||||
if weight and best["remaining_g"] + 1e-6 >= weight:
|
||||
score += 5
|
||||
elif weight and all(h["remaining_g"] + 1e-6 < weight for h in matched):
|
||||
return -1, "braucht %s (%s g fehlen)" % (
|
||||
_need_label(n),
|
||||
int(max(0, weight - max(h["remaining_g"] for h in matched))),
|
||||
)
|
||||
return score, ""
|
||||
|
||||
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"]}
|
||||
# Ohne Profil: falscher Typ hält den Job nicht fest (Sofort-Start warnt in der UI).
|
||||
score = 0 if (need_types and have_types and need_types.isdisjoint(have_types)) else 10
|
||||
for n in need:
|
||||
matched = _match_spools_for_need(n, have)
|
||||
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)" % (
|
||||
_need_label(n),
|
||||
int(max(0, weight - max(h["remaining_g"] for h in matched))),
|
||||
)
|
||||
return score, ""
|
||||
|
||||
|
||||
def auto_ace_map(conn, host, file_id):
|
||||
"""ACE-Map aus eingelegten Spulen: Sorte (Profil) zuerst, Farbe soft.
|
||||
|
||||
Rückgabe: (mapping, reason). reason gesetzt → nicht starten, warten.
|
||||
Ohne ACE-Spulen am Host: leere Map und kein Grund (externe Rolle).
|
||||
"""
|
||||
need, _weight = _file_needs(conn, file_id)
|
||||
have = [h for h in _spool_material_set(conn, host) if h.get("ace_slot")]
|
||||
if not need:
|
||||
return [], ""
|
||||
if not have:
|
||||
return [], ""
|
||||
mapping = []
|
||||
used = set()
|
||||
for n in need:
|
||||
matched = _match_spools_for_need(n, have, used)
|
||||
# Nur ACE-Plätze
|
||||
matched = [h for h in matched if h.get("ace_slot")]
|
||||
if not matched:
|
||||
return [], "braucht %s auf ACE" % _need_label(n)
|
||||
# Farbe bevorzugen, dann Rest
|
||||
matched.sort(
|
||||
key=lambda h: (
|
||||
0 if (n.get("color") and h["color"] == n["color"]) else 1,
|
||||
-h["remaining_g"],
|
||||
)
|
||||
)
|
||||
chosen = matched[0]
|
||||
used.add(chosen.get("spool_id"))
|
||||
try:
|
||||
paint = int(n.get("paint_index"))
|
||||
except (TypeError, ValueError):
|
||||
paint = len(mapping)
|
||||
ams = int(chosen["ace_slot"]) - 1
|
||||
if ams < 0 or ams > 7:
|
||||
return [], "ACE-Platz von %s ungültig" % _need_label(n)
|
||||
mapping.append({"paint_index": paint, "ams_index": ams})
|
||||
return mapping, ""
|
||||
|
||||
|
||||
def next_waiting_print(conn, host=None, family=None):
|
||||
"""Nächster wartender Druck: Termin (due_at) vor Priorität, Material-Match bevorzugt."""
|
||||
if host:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT q.*, f.name AS filename, o.due_at AS order_due_at
|
||||
FROM print_queue q
|
||||
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'
|
||||
AND (q.host = %s OR (q.host = '' AND (q.family = '' OR q.family = %s)))
|
||||
ORDER BY q.priority ASC, q.id ASC
|
||||
LIMIT 1
|
||||
ORDER BY
|
||||
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 ""),
|
||||
).fetchone()
|
||||
).fetchall()
|
||||
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
|
||||
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'
|
||||
ORDER BY q.priority ASC, q.id ASC
|
||||
LIMIT 1
|
||||
ORDER BY
|
||||
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()
|
||||
return row
|
||||
).fetchall()
|
||||
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):
|
||||
@@ -2625,7 +3044,10 @@ def ensure_bed_after_print(conn, host, ended_at=None, message=""):
|
||||
|
||||
Deckt verpasste Watcher-Übergänge ab (Neustart, Poll-Lücke): History und
|
||||
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()
|
||||
if not host:
|
||||
@@ -2639,6 +3061,9 @@ def ensure_bed_after_print(conn, host, ended_at=None, message=""):
|
||||
cleared = row["cleared_at"] if row else None
|
||||
if ended_at is not None and cleared is not None and cleared >= ended_at:
|
||||
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")
|
||||
return True
|
||||
|
||||
@@ -2770,3 +3195,74 @@ def revoke_api_token(conn, token_id, user_id=None):
|
||||
(token_id, user_id),
|
||||
).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)]
|
||||
|
||||
@@ -53,15 +53,23 @@ def planned_shares(metadata, ace_map, target_count):
|
||||
weights = weight_list(metadata)
|
||||
mapping = ace_map if isinstance(ace_map, list) else []
|
||||
if weights and mapping and len(weights) >= len(mapping) and target_count == len(mapping):
|
||||
shares = []
|
||||
paints = []
|
||||
for item in mapping:
|
||||
paint = item.get("paint_index")
|
||||
try:
|
||||
index = int(paint)
|
||||
paints.append(int(item.get("paint_index")))
|
||||
except (TypeError, ValueError):
|
||||
index = len(shares)
|
||||
if 0 <= index < len(weights):
|
||||
shares.append(weights[index])
|
||||
paints.append(-1)
|
||||
# Nur bei dichter 0…n−1-Map paint_index = Gewichtsindex; sonst Map-Reihenfolge
|
||||
# (Bambu/Anycubic oft T1+T10 bei zwei kompakten Gewichten).
|
||||
dense = sorted(paints) == list(range(len(paints))) and all(
|
||||
0 <= paint < len(weights) for paint in paints
|
||||
)
|
||||
shares = []
|
||||
for order, paint in enumerate(paints):
|
||||
if dense:
|
||||
shares.append(weights[paint])
|
||||
elif order < len(weights):
|
||||
shares.append(weights[order])
|
||||
else:
|
||||
shares.append(0.0)
|
||||
if sum(shares) > 0:
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""Version von Grotti (SemVer). Bei jeder Änderung CHANGELOG.md mitführen."""
|
||||
|
||||
__version__ = "3.0.0"
|
||||
__version__ = "3.3.0"
|
||||
@@ -6,7 +6,7 @@ import threading
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from grotti import moonraker, notify, store, usage
|
||||
from grotti import jobs, moonraker, notify, store, usage
|
||||
from grotti.events_bus import publish as publish_live
|
||||
from grotti.db import connect
|
||||
|
||||
@@ -139,11 +139,22 @@ class StateWatcher:
|
||||
)
|
||||
self._ensure_start_segment(conn, host, filename, live, run_id)
|
||||
elif prev_state == "printing" and state == "paused":
|
||||
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":
|
||||
self._emit(conn, host, name, "start", "%s druckt weiter." % name, filename, run_id)
|
||||
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(
|
||||
conn,
|
||||
host,
|
||||
@@ -159,16 +170,37 @@ class StateWatcher:
|
||||
jobs.sync_history(conn, self.cfg)
|
||||
except Exception as exc:
|
||||
print("Watcher Abgleich %s: %s" % (host, exc), flush=True)
|
||||
elif prev_state in ("printing", "paused") and state in ("cancelled", "standby"):
|
||||
store.ensure_bed_after_print(conn, host, message=filename or state)
|
||||
# History oft ohne Endzeit (interrupted) — Live trotzdem schließen.
|
||||
dangling = store.find_live_run(conn, host)
|
||||
if dangling:
|
||||
closed = store.complete_open_run(conn, dangling["id"], state="completed")
|
||||
if closed:
|
||||
try:
|
||||
from grotti import jobs
|
||||
|
||||
jobs.apply_deduction(conn, closed, self.cfg["energy"])
|
||||
except Exception as exc:
|
||||
print("Watcher Abzug %s: %s" % (host, exc), flush=True)
|
||||
elif state in ("cancelled", "standby"):
|
||||
# Live-Lauf schließen: normaler Abbruch, oder nach Offline/Neustart
|
||||
# (prev wird „offline“, der Übergang paused→standby geht sonst verloren).
|
||||
dangling = store.find_live_run(conn, host)
|
||||
from_active = prev_state in ("printing", "paused")
|
||||
recover = bool(dangling) and (prev_offline or not prev_state)
|
||||
if from_active or recover:
|
||||
file_label = filename or (dangling or {}).get("filename") or state
|
||||
store.set_bed_needs_clear(conn, host, True, file_label)
|
||||
store.abandon_live_runs(conn, host)
|
||||
self._emit(
|
||||
conn,
|
||||
host,
|
||||
name,
|
||||
"abbruch",
|
||||
"%s: Druck abgebrochen (%s)" % (name, filename or "ohne Datei"),
|
||||
filename,
|
||||
run_id or prev.get("run_id"),
|
||||
"%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
|
||||
@@ -178,7 +210,8 @@ class StateWatcher:
|
||||
print("Watcher Abgleich %s: %s" % (host, exc), flush=True)
|
||||
elif state == "error" and prev_state != "error":
|
||||
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()
|
||||
if not msg or msg.lower() in ("error", "fehler"):
|
||||
msg = "Ursache unbekannt — in Mainsail prüfen"
|
||||
@@ -202,7 +235,25 @@ class StateWatcher:
|
||||
if (live.get("nozzle_target") or 0) > 0 or (live.get("bed_target") or 0) > 0:
|
||||
self._cool_heaters(host)
|
||||
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"))
|
||||
if state == "printing" and not spool_warn:
|
||||
@@ -246,6 +297,8 @@ class StateWatcher:
|
||||
return None
|
||||
sent = store.latest_send(conn, host, filename) if filename else None
|
||||
spool_id = (sent or {}).get("spool_id")
|
||||
# Send kann nach Rollenwechsel veraltet sein — aktuelle Rolle am Host bevorzugt
|
||||
spool_id = jobs.resolve_tracking_spool(conn, host, spool_id, ace_map=(sent or {}).get("ace_map"))
|
||||
run = store.ensure_live_run(conn, host, name, filename, spool_id=spool_id)
|
||||
objects = self._merge_objects(run.get("objects"), live.get("exclude"))
|
||||
planned = usage.as_float(live.get("weight"))
|
||||
|
||||
@@ -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()
|
||||
@@ -13,8 +13,11 @@ from __future__ import annotations
|
||||
import json
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
import webbrowser
|
||||
from pathlib import Path
|
||||
@@ -25,7 +28,19 @@ from urllib.request import Request, urlopen
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
CONFIG_PATH = REPO_ROOT / "data" / "orca-senden.json"
|
||||
DEBUG_LOG = REPO_ROOT / "data" / "orca-senden-last.log"
|
||||
BROWSER_LOCK = REPO_ROOT / "data" / "orca-senden-browser.lock"
|
||||
FOCUS_PATH = REPO_ROOT / "data" / "orca-senden-focus.json"
|
||||
APP_PROFILE = REPO_ROOT / "data" / "grotti-app-profile"
|
||||
ALLOWED_SUFFIXES = (".gcode", ".gcode.3mf", ".3mf", ".gcode.pp", ".pp")
|
||||
BROWSER_DEBOUNCE_S = 25.0
|
||||
# Anycubic: MMDD-HHMM-Name.gcode — Zeitstempel raus, sonst jedes Export ein neues Fenster
|
||||
_TIME_PREFIX_RE = re.compile(r"^\d{4}-\d{4}-")
|
||||
_CHROMIUM_CANDIDATES = (
|
||||
Path(r"C:\Program Files\Google\Chrome\Application\chrome.exe"),
|
||||
Path(r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe"),
|
||||
Path(os.path.expandvars(r"%LOCALAPPDATA%\Microsoft\Edge\Application\msedge.exe")),
|
||||
Path(os.path.expandvars(r"%LOCALAPPDATA%\Google\Chrome\Application\chrome.exe")),
|
||||
)
|
||||
|
||||
|
||||
def show_error(message: str) -> None:
|
||||
@@ -76,7 +91,10 @@ def load_config(path: Path = CONFIG_PATH) -> dict[str, str]:
|
||||
raise ValueError("Config: url fehlt (z. B. http://127.0.0.1:8770).")
|
||||
if not token:
|
||||
raise ValueError("Config: token fehlt (grt_… aus Einstellungen).")
|
||||
return {"url": url, "token": token}
|
||||
browser = str(data.get("browser") or "app").strip().lower() or "app"
|
||||
if browser not in ("app", "tab"):
|
||||
browser = "app"
|
||||
return {"url": url, "token": token, "browser": browser}
|
||||
|
||||
|
||||
def is_gcode_path(path: Path) -> bool:
|
||||
@@ -140,6 +158,83 @@ def send_page_url(base_url: str, file_id: int) -> str:
|
||||
return "%s/dateien?file=%s" % (base_url.rstrip("/"), int(file_id))
|
||||
|
||||
|
||||
def app_page_url(base_url: str) -> str:
|
||||
"""Stabile App-URL — Datei kommt über orca-fokus, kein neues Fenster pro Slice."""
|
||||
return "%s/dateien?orca=1" % base_url.rstrip("/")
|
||||
|
||||
|
||||
def find_chromium() -> Path | None:
|
||||
for path in _CHROMIUM_CANDIDATES:
|
||||
try:
|
||||
if path.is_file():
|
||||
return path
|
||||
except OSError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def app_already_running() -> bool:
|
||||
"""True wenn Chrome/Edge schon mit grotti-app-profile läuft (Profil-Sperre)."""
|
||||
needle = "grotti-app-profile"
|
||||
if sys.platform == "win32":
|
||||
try:
|
||||
creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||
out = subprocess.check_output(
|
||||
[
|
||||
"powershell",
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
(
|
||||
"Get-CimInstance Win32_Process | "
|
||||
"Where-Object { "
|
||||
"($_.Name -match '^(chrome|msedge)\\.exe$') -and "
|
||||
"($_.CommandLine -like '*%s*') "
|
||||
"} | Select-Object -First 1 -ExpandProperty ProcessId"
|
||||
)
|
||||
% needle,
|
||||
],
|
||||
timeout=8,
|
||||
stderr=subprocess.DEVNULL,
|
||||
creationflags=creationflags,
|
||||
)
|
||||
return bool((out or b"").strip())
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
pass
|
||||
for name in ("SingletonLock", "SingletonSocket", "lockfile"):
|
||||
path = APP_PROFILE / name
|
||||
try:
|
||||
if path.exists() or path.is_symlink():
|
||||
return True
|
||||
except OSError:
|
||||
continue
|
||||
return False
|
||||
|
||||
|
||||
def open_as_app(url: str) -> bool:
|
||||
"""Chrome/Edge --app=… → eigenes Fenster auf dem Desktop, kein Browser-Tab."""
|
||||
binary = find_chromium()
|
||||
if binary is None:
|
||||
return False
|
||||
try:
|
||||
APP_PROFILE.mkdir(parents=True, exist_ok=True)
|
||||
subprocess.Popen(
|
||||
[
|
||||
str(binary),
|
||||
"--app=%s" % url,
|
||||
"--user-data-dir=%s" % str(APP_PROFILE.resolve()),
|
||||
"--no-first-run",
|
||||
"--no-default-browser-check",
|
||||
],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
close_fds=True,
|
||||
)
|
||||
return True
|
||||
except OSError as exc:
|
||||
write_debug(sys.argv, "app_open_fail=%s" % exc)
|
||||
return False
|
||||
|
||||
|
||||
def _api_json(
|
||||
base_url: str,
|
||||
token: str,
|
||||
@@ -279,7 +374,79 @@ def upload_file(
|
||||
return payload
|
||||
|
||||
|
||||
def open_browser(url: str) -> None:
|
||||
def browser_key(name: str) -> str:
|
||||
"""Gleicher Slice trotz Zeitstempel / -2 / Leerzeichen → ein Browserfenster."""
|
||||
stem = Path(name or "").stem.lower()
|
||||
stem = _TIME_PREFIX_RE.sub("", stem)
|
||||
stem = re.sub(r"-\d+$", "", stem)
|
||||
return re.sub(r"[^a-z0-9]+", "", stem)
|
||||
|
||||
|
||||
def write_focus(file_id: int, url: str, key: str = "") -> None:
|
||||
"""Offene Dateien-Seite kann auf die neueste Orca-Datei umschalten."""
|
||||
try:
|
||||
FOCUS_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
FOCUS_PATH.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"file_id": int(file_id),
|
||||
"url": url,
|
||||
"key": key,
|
||||
"ts": time.time(),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
except (OSError, TypeError, ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def open_browser(
|
||||
url: str,
|
||||
key: str = "",
|
||||
file_id: int | None = None,
|
||||
mode: str = "app",
|
||||
) -> None:
|
||||
"""Anycubic/Orca starten das Skript oft 2× — nur ein Fenster pro Modell.
|
||||
|
||||
mode=app (Standard): Chrome/Edge als App-Fenster auf dem Desktop.
|
||||
mode=tab: klassischer Browser-Tab.
|
||||
"""
|
||||
token = (key or url).strip() or url
|
||||
now = time.time()
|
||||
if file_id is not None:
|
||||
write_focus(int(file_id), url, key=token)
|
||||
try:
|
||||
BROWSER_LOCK.parent.mkdir(parents=True, exist_ok=True)
|
||||
if BROWSER_LOCK.is_file():
|
||||
prev = json.loads(BROWSER_LOCK.read_text(encoding="utf-8"))
|
||||
if (
|
||||
isinstance(prev, dict)
|
||||
and prev.get("key") == token
|
||||
and now - float(prev.get("ts") or 0) < BROWSER_DEBOUNCE_S
|
||||
):
|
||||
write_debug(
|
||||
sys.argv,
|
||||
"browser_skip key=%s age=%.1fs focus=%s"
|
||||
% (token, now - float(prev.get("ts") or 0), file_id),
|
||||
)
|
||||
return
|
||||
BROWSER_LOCK.write_text(
|
||||
json.dumps({"key": token, "ts": now, "url": url, "mode": mode}, ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
except (OSError, TypeError, ValueError, json.JSONDecodeError):
|
||||
pass
|
||||
if (mode or "app").strip().lower() != "tab":
|
||||
# Zweites Chrome mit gleichem Profil = leeres Profil / erneutes Login.
|
||||
# Offenes Fenster pollt orca-fokus und wechselt die Datei selbst.
|
||||
if app_already_running():
|
||||
write_debug(sys.argv, "browser_reuse focus=%s" % file_id)
|
||||
return
|
||||
if open_as_app(url):
|
||||
write_debug(sys.argv, "browser_app url=%s" % url)
|
||||
return
|
||||
webbrowser.open(url)
|
||||
|
||||
|
||||
@@ -319,8 +486,21 @@ def main(argv: list[str] | None = None) -> int:
|
||||
% (path, upload_name_for(path), output_target() or ""),
|
||||
)
|
||||
file_id = deliver(config["url"], config["token"], path)
|
||||
write_debug(argv, "file_id=%s" % file_id)
|
||||
open_browser(send_page_url(config["url"], file_id))
|
||||
name = upload_name_for(path)
|
||||
write_debug(argv, "file_id=%s\nupload_as=%s" % (file_id, name))
|
||||
mode = str(config.get("browser") or "app").strip().lower()
|
||||
# App: stabile URL; Tab: Deep-Link mit file=
|
||||
open_url = (
|
||||
send_page_url(config["url"], file_id)
|
||||
if mode == "tab"
|
||||
else app_page_url(config["url"])
|
||||
)
|
||||
open_browser(
|
||||
open_url,
|
||||
key=browser_key(name),
|
||||
file_id=file_id,
|
||||
mode=mode,
|
||||
)
|
||||
except Exception as exc:
|
||||
write_debug(argv, "error=%s" % exc)
|
||||
show_error(str(exc))
|
||||
|
||||
@@ -64,6 +64,10 @@ def main():
|
||||
dispatcher.start()
|
||||
if hasattr(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()
|
||||
retention = threading.Thread(
|
||||
@@ -90,6 +94,10 @@ def main():
|
||||
send_queue.stop()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
daily.stop()
|
||||
except Exception:
|
||||
pass
|
||||
db.close_pool()
|
||||
|
||||
atexit.register(shutdown)
|
||||
|
||||
@@ -48,6 +48,7 @@ class CameraFieldsTest(unittest.TestCase):
|
||||
moonraker.CAMERA_CACHE.clear()
|
||||
fields = moonraker._camera_fields("10.0.0.1", "10.0.0.1")
|
||||
self.assertTrue(fields["camera"])
|
||||
self.assertIn("/api/v1/kamera/stream?", fields["camera_stream"])
|
||||
self.assertIsNone(fields["has_camera"])
|
||||
|
||||
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)
|
||||
fields = moonraker._camera_fields("10.0.0.2", "10.0.0.2")
|
||||
self.assertEqual(fields["camera"], "")
|
||||
self.assertEqual(fields["camera_stream"], "")
|
||||
self.assertFalse(fields["has_camera"])
|
||||
moonraker.CAMERA_CACHE.pop("10.0.0.2", None)
|
||||
|
||||
|
||||
@@ -26,8 +26,7 @@ class BedarfTests(unittest.TestCase):
|
||||
"repeat_total": 2,
|
||||
"repeat_done": 0,
|
||||
"weight_g": 50.0,
|
||||
"file_material": "PLA",
|
||||
"file_color": "Schwarz",
|
||||
"filaments": [{"type": "PLA", "color": "Schwarz"}],
|
||||
"spool_material": "PLA",
|
||||
"spool_color": "Schwarz",
|
||||
},
|
||||
@@ -52,8 +51,7 @@ class BedarfTests(unittest.TestCase):
|
||||
"repeat_total": 1,
|
||||
"repeat_done": 0,
|
||||
"weight_g": 20.0,
|
||||
"file_material": "PETG",
|
||||
"file_color": "",
|
||||
"filaments": [{"type": "PETG", "color": ""}],
|
||||
"spool_material": None,
|
||||
"spool_color": None,
|
||||
},
|
||||
|
||||
@@ -46,6 +46,22 @@ class EnergyEdTests(unittest.TestCase):
|
||||
self.assertAlmostEqual(est["print_kwh"], 0.2, 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__":
|
||||
unittest.main()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -28,6 +28,47 @@ class ChooseSpoolTests(unittest.TestCase):
|
||||
with patch("grotti.jobs.store.spools_for_printer", return_value=[old]):
|
||||
self.assertEqual(jobs.choose_spool(conn, "h", "PLA", None, ended_at=end)["id"], 1)
|
||||
|
||||
def test_prefers_host_spool_over_stale_hint(self):
|
||||
"""Rollenwechsel: Hint im Regal, neue Rolle am Drucker → Abzug auf die neue."""
|
||||
end = datetime(2026, 10, 18, 14, 58, tzinfo=timezone.utc)
|
||||
hinted = {
|
||||
"id": 4,
|
||||
"material_type": "PLA",
|
||||
"printer_host": "",
|
||||
"created_at": end - timedelta(days=5),
|
||||
}
|
||||
current = {
|
||||
"id": 14,
|
||||
"material_type": "PLA",
|
||||
"printer_host": "h",
|
||||
"created_at": end - timedelta(minutes=10),
|
||||
}
|
||||
conn = MagicMock()
|
||||
with patch("grotti.jobs.store.spools_for_printer", return_value=[current]):
|
||||
with patch("grotti.jobs.store.get_spool_row", return_value=hinted):
|
||||
self.assertEqual(jobs.choose_spool(conn, "h", "PLA", 4, ended_at=end)["id"], 14)
|
||||
|
||||
def test_keeps_hint_when_no_host_spool(self):
|
||||
end = datetime(2026, 10, 18, 14, 58, tzinfo=timezone.utc)
|
||||
hinted = {
|
||||
"id": 4,
|
||||
"material_type": "PLA",
|
||||
"printer_host": "",
|
||||
"created_at": end - timedelta(days=5),
|
||||
}
|
||||
conn = MagicMock()
|
||||
with patch("grotti.jobs.store.spools_for_printer", return_value=[]):
|
||||
with patch("grotti.jobs.store.get_spool_row", return_value=hinted):
|
||||
self.assertEqual(jobs.choose_spool(conn, "h", "PLA", 4, ended_at=end)["id"], 4)
|
||||
|
||||
def test_resolve_tracking_spool_prefers_host(self):
|
||||
conn = MagicMock()
|
||||
with patch(
|
||||
"grotti.jobs.resolve_spools_at_host",
|
||||
return_value=[{"id": 14, "printer_host": "h"}],
|
||||
):
|
||||
self.assertEqual(jobs.resolve_tracking_spool(conn, "h", 4), 14)
|
||||
|
||||
def test_spool_valid_for_end_naive(self):
|
||||
end = datetime(2026, 10, 1, 12, 0, tzinfo=timezone.utc)
|
||||
spool = {"created_at": datetime(2026, 9, 1, 12, 0)}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Sorten-Routing: Profil (Meta/Matte) und Auto-ACE-Map."""
|
||||
|
||||
import unittest
|
||||
|
||||
from grotti import store
|
||||
|
||||
|
||||
class ProfileMatchTests(unittest.TestCase):
|
||||
def test_meta_not_matte(self):
|
||||
self.assertTrue(store._profile_matches("Sunlu PLA Meta", "Sunlu PLA Meta"))
|
||||
self.assertTrue(store._profile_matches("Sunlu PLA Meta", "Sunlu PLA Meta schwarz"))
|
||||
self.assertFalse(store._profile_matches("Sunlu PLA Meta", "Sunlu PLA Matte"))
|
||||
self.assertFalse(store._profile_matches("Sunlu PLA Matte", "Sunlu PLA"))
|
||||
self.assertFalse(store._profile_matches("Sunlu PLA", "Sunlu PLA Matte"))
|
||||
self.assertFalse(store._profile_matches("Sunlu PLA", "Sunlu PLA Meta"))
|
||||
|
||||
def test_empty_profile_always_ok(self):
|
||||
self.assertTrue(store._profile_matches("", "Sunlu PLA Meta"))
|
||||
|
||||
|
||||
class MatchSpoolsTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.have = [
|
||||
{
|
||||
"spool_id": 1,
|
||||
"material_type": "pla",
|
||||
"color": "#ff0000",
|
||||
"remaining_g": 800,
|
||||
"material_name": "sunlu pla meta",
|
||||
"ace_slot": 1,
|
||||
},
|
||||
{
|
||||
"spool_id": 2,
|
||||
"material_type": "pla",
|
||||
"color": "#000000",
|
||||
"remaining_g": 500,
|
||||
"material_name": "sunlu pla matte",
|
||||
"ace_slot": 2,
|
||||
},
|
||||
]
|
||||
|
||||
def test_picks_meta_not_matte(self):
|
||||
need = {"profile": "Sunlu PLA Meta", "material_type": "pla", "color": "", "paint_index": 0}
|
||||
matched = store._match_spools_for_need(need, self.have)
|
||||
self.assertEqual(len(matched), 1)
|
||||
self.assertEqual(matched[0]["spool_id"], 1)
|
||||
|
||||
def test_color_soft_among_profile(self):
|
||||
need = {
|
||||
"profile": "Sunlu PLA Meta",
|
||||
"material_type": "pla",
|
||||
"color": "#00ff00",
|
||||
"paint_index": 0,
|
||||
}
|
||||
# Nur Meta vorhanden, Farbe anders → trotzdem Meta (Farbe soft)
|
||||
matched = store._match_spools_for_need(need, self.have)
|
||||
self.assertEqual(matched[0]["spool_id"], 1)
|
||||
|
||||
def test_auto_ace_map_assigns_slots(self):
|
||||
# Ohne DB: Logik über _match + manuell wie auto_ace_map
|
||||
need = [
|
||||
{"profile": "Sunlu PLA Meta", "material_type": "pla", "color": "#ff0000", "paint_index": 1},
|
||||
{"profile": "Sunlu PLA Matte", "material_type": "pla", "color": "#000000", "paint_index": 10},
|
||||
]
|
||||
used = set()
|
||||
mapping = []
|
||||
for n in need:
|
||||
matched = store._match_spools_for_need(n, self.have, used)
|
||||
self.assertTrue(matched)
|
||||
chosen = matched[0]
|
||||
used.add(chosen["spool_id"])
|
||||
mapping.append({"paint_index": n["paint_index"], "ams_index": int(chosen["ace_slot"]) - 1})
|
||||
self.assertEqual(mapping, [
|
||||
{"paint_index": 1, "ams_index": 0},
|
||||
{"paint_index": 10, "ams_index": 1},
|
||||
])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -23,6 +23,90 @@ class OrcaSendenHelpersTest(unittest.TestCase):
|
||||
"http://127.0.0.1:8770/dateien?file=42",
|
||||
)
|
||||
|
||||
def test_app_page_url(self):
|
||||
self.assertEqual(
|
||||
orca.app_page_url("http://127.0.0.1:8770/"),
|
||||
"http://127.0.0.1:8770/dateien?orca=1",
|
||||
)
|
||||
|
||||
def test_browser_key_collapses_duplicates(self):
|
||||
self.assertEqual(
|
||||
orca.browser_key("1018-1648-Round Clip PLA + PETG(01)_PLA_0.2_30m34s.gcode"),
|
||||
orca.browser_key("1018-1648-Round_Clip_PLA_+_PETG(01)_PLA_0.2_30m34s-2.gcode"),
|
||||
)
|
||||
|
||||
def test_browser_key_strips_time_prefix(self):
|
||||
self.assertEqual(
|
||||
orca.browser_key("1018-1706-Round Clip PLA + PETG(01)_PLA_0.2_30m34s.gcode"),
|
||||
orca.browser_key("1018-1709-Round_Clip_PLA_+_PETG(01)_PLA_0.2_30m34s.gcode"),
|
||||
)
|
||||
self.assertEqual(
|
||||
orca.browser_key("1018-1709-Round Clip PLA + PETG(01)_PLA_0.2_30m34s.gcode"),
|
||||
"roundclipplapetg01pla0230m34s",
|
||||
)
|
||||
|
||||
def test_open_browser_debounces(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
lock = Path(tmp) / "browser.lock"
|
||||
focus = Path(tmp) / "focus.json"
|
||||
with patch.object(orca, "BROWSER_LOCK", lock):
|
||||
with patch.object(orca, "FOCUS_PATH", focus):
|
||||
with patch.object(orca, "open_as_app", return_value=False):
|
||||
with patch.object(orca, "webbrowser") as browser:
|
||||
orca.open_browser(
|
||||
"http://127.0.0.1:8770/dateien?file=1",
|
||||
key="roundclip",
|
||||
file_id=1,
|
||||
mode="tab",
|
||||
)
|
||||
orca.open_browser(
|
||||
"http://127.0.0.1:8770/dateien?file=2",
|
||||
key="roundclip",
|
||||
file_id=2,
|
||||
mode="tab",
|
||||
)
|
||||
self.assertEqual(browser.open.call_count, 1)
|
||||
data = json.loads(focus.read_text(encoding="utf-8"))
|
||||
self.assertEqual(data["file_id"], 2)
|
||||
|
||||
def test_open_browser_app_mode(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
lock = Path(tmp) / "browser.lock"
|
||||
focus = Path(tmp) / "focus.json"
|
||||
with patch.object(orca, "BROWSER_LOCK", lock):
|
||||
with patch.object(orca, "FOCUS_PATH", focus):
|
||||
with patch.object(orca, "app_already_running", return_value=False):
|
||||
with patch.object(orca, "open_as_app", return_value=True) as app:
|
||||
with patch.object(orca, "webbrowser") as browser:
|
||||
orca.open_browser(
|
||||
"http://127.0.0.1:8770/dateien?orca=1",
|
||||
key="roundclip",
|
||||
file_id=9,
|
||||
mode="app",
|
||||
)
|
||||
app.assert_called_once()
|
||||
browser.open.assert_not_called()
|
||||
|
||||
def test_open_browser_reuses_running_app(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
lock = Path(tmp) / "browser.lock"
|
||||
focus = Path(tmp) / "focus.json"
|
||||
with patch.object(orca, "BROWSER_LOCK", lock):
|
||||
with patch.object(orca, "FOCUS_PATH", focus):
|
||||
with patch.object(orca, "app_already_running", return_value=True):
|
||||
with patch.object(orca, "open_as_app", return_value=True) as app:
|
||||
with patch.object(orca, "webbrowser") as browser:
|
||||
orca.open_browser(
|
||||
"http://127.0.0.1:8770/dateien?orca=1",
|
||||
key="andere-datei",
|
||||
file_id=11,
|
||||
mode="app",
|
||||
)
|
||||
app.assert_not_called()
|
||||
browser.open.assert_not_called()
|
||||
data = json.loads(focus.read_text(encoding="utf-8"))
|
||||
self.assertEqual(data["file_id"], 11)
|
||||
|
||||
def test_find_gcode_arg_last_matching(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
folder = Path(tmp)
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Sparse paint_index / Bambu-T-Nummern für ACE-Map."""
|
||||
|
||||
import json
|
||||
import unittest
|
||||
|
||||
from grotti import acm, parse, usage
|
||||
|
||||
|
||||
PAINT_SPARSE = json.dumps(
|
||||
[
|
||||
{"material_type": "PLA", "paint_color": [255, 128, 0], "paint_index": 1},
|
||||
{"material_type": "PLA", "paint_color": [0, 0, 0], "paint_index": 10},
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class PaintIndexTests(unittest.TestCase):
|
||||
def test_filaments_keep_slicer_paint_index(self):
|
||||
rows = parse.filaments_from_paint_info(PAINT_SPARSE)
|
||||
self.assertEqual(len(rows), 2)
|
||||
self.assertEqual(rows[0]["paint_index"], 1)
|
||||
self.assertEqual(rows[0]["type"], "PLA")
|
||||
self.assertEqual(rows[0]["color"], "#ff8000")
|
||||
self.assertEqual(rows[1]["paint_index"], 10)
|
||||
self.assertEqual(rows[1]["color"], "#000000")
|
||||
|
||||
def test_build_acm_uses_paint_index_not_array_slot(self):
|
||||
filaments = parse.filaments_from_paint_info(PAINT_SPARSE)
|
||||
mapping = [
|
||||
{"paint_index": 1, "ams_index": 3},
|
||||
{"paint_index": 10, "ams_index": 2},
|
||||
]
|
||||
slots = [
|
||||
{"index": 2, "type": "PLA", "color": "#f4f4f4"},
|
||||
{"index": 3, "type": "PLA", "color": "#c23b3b"},
|
||||
]
|
||||
payload = acm.build_acm(filaments, mapping, slots)
|
||||
paints = [item["paint_index"] for item in payload["ams_box_mapping"]]
|
||||
self.assertEqual(paints, [1, 10])
|
||||
by_paint = {item["paint_index"]: item for item in payload["ams_box_mapping"]}
|
||||
self.assertEqual(by_paint[1]["ams_index"], 3)
|
||||
self.assertEqual(by_paint[10]["ams_index"], 2)
|
||||
self.assertEqual(by_paint[1]["paint_color"], [255, 128, 0])
|
||||
self.assertEqual(by_paint[10]["paint_color"], [0, 0, 0])
|
||||
|
||||
def test_densify_mapping_and_rewrite_avoids_invalid_filament_id(self):
|
||||
mapping = [
|
||||
{"paint_index": 1, "ams_index": 3},
|
||||
{"paint_index": 10, "ams_index": 2},
|
||||
]
|
||||
remap, dense = acm.densify_mapping(mapping)
|
||||
self.assertEqual(remap, {1: 0, 10: 1})
|
||||
self.assertEqual(
|
||||
dense,
|
||||
[
|
||||
{"paint_index": 0, "ams_index": 3},
|
||||
{"paint_index": 1, "ams_index": 2},
|
||||
],
|
||||
)
|
||||
gcode = b"; hdr\nT10\nG1 X0\nT1 ; change\n; referenced_tools = 1,10\n"
|
||||
out = acm.rewrite_gcode_tools(gcode, remap)
|
||||
self.assertIn(b"T1\n", out)
|
||||
self.assertIn(b"T0 ; change", out)
|
||||
self.assertNotIn(b"T10", out)
|
||||
self.assertIn(b"; referenced_tools = 0,1", out)
|
||||
filaments = acm.remap_filaments(
|
||||
parse.filaments_from_paint_info(PAINT_SPARSE), remap
|
||||
)
|
||||
slots = [
|
||||
{"index": 2, "type": "PLA", "color": "#f4f4f4"},
|
||||
{"index": 3, "type": "PLA", "color": "#c23b3b"},
|
||||
]
|
||||
payload = acm.build_acm(filaments, dense, slots)
|
||||
paints = [item["paint_index"] for item in payload["ams_box_mapping"]]
|
||||
self.assertEqual(paints, [0, 1])
|
||||
self.assertEqual(payload["ams_box_mapping"][0]["paint_color"], [255, 128, 0])
|
||||
|
||||
def test_densify_identity_when_already_dense(self):
|
||||
mapping = [{"paint_index": 0, "ams_index": 1}, {"paint_index": 1, "ams_index": 2}]
|
||||
remap, dense = acm.densify_mapping(mapping)
|
||||
self.assertEqual(remap, {})
|
||||
self.assertEqual(dense, mapping)
|
||||
|
||||
def test_align_rewrites_when_ui_map_is_already_dense(self):
|
||||
"""Regression 10011700: UI schickt 0/1, G-code hat noch T1/T10."""
|
||||
gcode = b"; hdr\nT10\nG1 X0\nT1 ; change\n; referenced_tools = 1,10\n"
|
||||
mapping = [
|
||||
{"paint_index": 0, "ams_index": 3},
|
||||
{"paint_index": 1, "ams_index": 2},
|
||||
]
|
||||
filaments = [
|
||||
{"type": "PLA", "color": "#ff8000", "paint_index": 0},
|
||||
{"type": "PLA", "color": "#000000", "paint_index": 1},
|
||||
]
|
||||
out, dense, fils, remap = acm.align_tools_for_ace(gcode, mapping, filaments)
|
||||
self.assertEqual(remap, {1: 0, 10: 1})
|
||||
self.assertEqual(
|
||||
dense,
|
||||
[
|
||||
{"paint_index": 0, "ams_index": 3},
|
||||
{"paint_index": 1, "ams_index": 2},
|
||||
],
|
||||
)
|
||||
self.assertIn(b"T1\n", out)
|
||||
self.assertIn(b"T0 ; change", out)
|
||||
self.assertNotIn(b"T10", out)
|
||||
self.assertIn(b"; referenced_tools = 0,1", out)
|
||||
self.assertEqual([row["paint_index"] for row in fils], [0, 1])
|
||||
self.assertEqual(fils[0]["color"], "#ff8000")
|
||||
self.assertEqual(fils[1]["color"], "#000000")
|
||||
|
||||
def test_align_with_sparse_ui_map(self):
|
||||
gcode = b"T10\nT1\n"
|
||||
mapping = [
|
||||
{"paint_index": 1, "ams_index": 3},
|
||||
{"paint_index": 10, "ams_index": 2},
|
||||
]
|
||||
out, dense, fils, remap = acm.align_tools_for_ace(
|
||||
gcode, mapping, parse.filaments_from_paint_info(PAINT_SPARSE)
|
||||
)
|
||||
self.assertEqual(remap, {1: 0, 10: 1})
|
||||
self.assertEqual(dense[0]["ams_index"], 3)
|
||||
self.assertEqual(dense[1]["ams_index"], 2)
|
||||
self.assertNotIn(b"T10", out)
|
||||
self.assertEqual([row["paint_index"] for row in fils], [0, 1])
|
||||
|
||||
def test_tools_used_finds_t10(self):
|
||||
gcode = b"; header\nT10\nG1 X0\nT1 ; change\n"
|
||||
self.assertEqual(parse.tools_used_in_gcode(gcode), {1, 10})
|
||||
|
||||
def test_planned_shares_sparse_paint_falls_back_to_order(self):
|
||||
meta = {"filament_weight_total": [12.0, 8.0]}
|
||||
mapping = [
|
||||
{"paint_index": 1, "ams_index": 0},
|
||||
{"paint_index": 10, "ams_index": 1},
|
||||
]
|
||||
self.assertEqual(usage.planned_shares(meta, mapping, 2), [12.0, 8.0])
|
||||
|
||||
def test_profile_name_strips_machine(self):
|
||||
self.assertEqual(parse.profile_name('Sunlu PLA Meta @Kobra 3'), "Sunlu PLA Meta")
|
||||
self.assertEqual(parse.profile_name('"Sunlu PLA Matte @Kobra S1"'), "Sunlu PLA Matte")
|
||||
|
||||
def test_filaments_of_attaches_settings_id_profiles(self):
|
||||
# Slot 1 = PLA, Slot 10 = Matte — wie sparse paint_info
|
||||
profiles = ";".join(
|
||||
['"Sunlu PLA @Kobra 3"' if i not in (1, 10) else (
|
||||
'"Sunlu PLA @Kobra 3"' if i == 1 else '"Sunlu PLA Matte @Kobra 3"'
|
||||
) for i in range(15)]
|
||||
)
|
||||
comments = {
|
||||
"paint_info": PAINT_SPARSE,
|
||||
"filament_settings_id": profiles,
|
||||
}
|
||||
rows = parse.filaments_of(comments)
|
||||
self.assertEqual(rows[0]["profile"], "Sunlu PLA")
|
||||
self.assertEqual(rows[1]["profile"], "Sunlu PLA Matte")
|
||||
|
||||
def test_filaments_of_single_without_paint(self):
|
||||
comments = {
|
||||
"filament_type": "PLA",
|
||||
"filament_colour": "#00FF00",
|
||||
"filament_settings_id": '"Sunlu PLA Meta @Kobra 3"',
|
||||
}
|
||||
rows = parse.filaments_of(comments)
|
||||
self.assertEqual(len(rows), 1)
|
||||
self.assertEqual(rows[0]["profile"], "Sunlu PLA Meta")
|
||||
self.assertEqual(rows[0]["type"], "PLA")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -38,13 +38,17 @@ class DispatcherHelpers(unittest.TestCase):
|
||||
class ResolveSpoolTests(unittest.TestCase):
|
||||
def test_material_match(self):
|
||||
from grotti.jobs import assert_material_match
|
||||
from grotti.errors import GrottiError
|
||||
|
||||
file_row = {"filaments": [{"type": "PLA"}]}
|
||||
assert_material_match(file_row, [{"material_type": "PLA"}])
|
||||
with self.assertRaises(GrottiError):
|
||||
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__":
|
||||
unittest.main()
|
||||
@@ -5,13 +5,13 @@ import re
|
||||
import secrets
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
|
||||
import markdown
|
||||
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 psycopg.rows import dict_row
|
||||
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})?$")
|
||||
TOKEN_QUERY_PATHS = (
|
||||
"/api/v1/kamera",
|
||||
"/api/v1/kamera/stream",
|
||||
"/api/v1/vorschau",
|
||||
)
|
||||
STEER_ACTIONS = {
|
||||
@@ -76,6 +77,9 @@ def create_app(cfg):
|
||||
app.config["MAX_CONTENT_LENGTH"] = 256 * 1024 * 1024
|
||||
app.config["SESSION_COOKIE_HTTPONLY"] = True
|
||||
app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
|
||||
# Orca-App-Fenster schließt oft — Session-Cookie muss überleben (30 Tage, gleitend).
|
||||
app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(days=30)
|
||||
app.config["SESSION_REFRESH_EACH_REQUEST"] = True
|
||||
if cfg.get("fqdn", "").startswith("https://"):
|
||||
app.config["SESSION_COOKIE_SECURE"] = True
|
||||
app.json.ensure_ascii = False
|
||||
@@ -103,6 +107,7 @@ def create_app(cfg):
|
||||
)
|
||||
ADMIN_API_EXACT = {
|
||||
"/api/v1/telegram/test",
|
||||
"/api/v1/ntfy/test",
|
||||
}
|
||||
ADMIN_METHODS_ON = {
|
||||
# Drucker anlegen/ändern/löschen nur Admin
|
||||
@@ -135,14 +140,6 @@ def create_app(cfg):
|
||||
return user
|
||||
|
||||
def resolve_bearer(presented):
|
||||
legacy = cfg.get("token") or ""
|
||||
if legacy and secrets.compare_digest(presented, legacy):
|
||||
return {
|
||||
"id": None,
|
||||
"username": "legacy-token",
|
||||
"role": "admin",
|
||||
"auth": "legacy",
|
||||
}
|
||||
if not presented.startswith(grotti_auth.TOKEN_PREFIX):
|
||||
return None
|
||||
row = store.get_api_token_by_hash(conn(), grotti_auth.hash_token(presented))
|
||||
@@ -250,10 +247,10 @@ def create_app(cfg):
|
||||
if path == "/api/v1/einstellungen" and request.method == "GET":
|
||||
need_admin = False
|
||||
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 {}
|
||||
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"):
|
||||
need_admin = True
|
||||
elif request.method != "GET":
|
||||
@@ -347,6 +344,24 @@ def create_app(cfg):
|
||||
require_host(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
|
||||
def inject_globals():
|
||||
user = auth_user()
|
||||
@@ -354,6 +369,7 @@ def create_app(cfg):
|
||||
"version": __version__,
|
||||
"csrf": session.get("csrf") or "",
|
||||
"current_user": user,
|
||||
"icons_sprite": Markup(icons_sprite_html()),
|
||||
}
|
||||
|
||||
def page(name, title):
|
||||
@@ -408,6 +424,7 @@ def create_app(cfg):
|
||||
grotti_db.invalidate_user_count()
|
||||
store.touch_user_login(conn(), row["id"])
|
||||
session.clear()
|
||||
session.permanent = True
|
||||
session["user_id"] = row["id"]
|
||||
session["csrf"] = grotti_auth.new_csrf()
|
||||
store.add_event(conn(), "", "", "auth", "Admin %s angelegt" % username)
|
||||
@@ -429,6 +446,7 @@ def create_app(cfg):
|
||||
raise GrottiError("Benutzer oder Passwort falsch.", "auth", 401)
|
||||
store.touch_user_login(conn(), row["id"])
|
||||
session.clear()
|
||||
session.permanent = True
|
||||
session["user_id"] = row["id"]
|
||||
session["csrf"] = grotti_auth.new_csrf()
|
||||
store.add_event(conn(), "", "", "auth", "Login %s" % row["username"])
|
||||
@@ -620,7 +638,8 @@ def create_app(cfg):
|
||||
|
||||
@app.get("/kameras")
|
||||
def kameras():
|
||||
return page("kameras.html", "Kameras")
|
||||
"""Alte Links → Wand (Kameras sitzen als Snapshot-Thumbs auf den Karten)."""
|
||||
return redirect("/", code=301)
|
||||
|
||||
@app.get("/dateien")
|
||||
def dateien():
|
||||
@@ -628,7 +647,7 @@ def create_app(cfg):
|
||||
|
||||
@app.get("/projekte")
|
||||
def projekte():
|
||||
return redirect("/auftraege", code=301)
|
||||
return page("projekte.html", "Projekte")
|
||||
|
||||
@app.get("/auftraege")
|
||||
def auftraege_page():
|
||||
@@ -739,6 +758,10 @@ def create_app(cfg):
|
||||
def ideen():
|
||||
return redirect("/auftraege", code=301)
|
||||
|
||||
@app.get("/jetzt")
|
||||
def jetzt_page():
|
||||
return page("jetzt.html", "Jetzt")
|
||||
|
||||
@app.get("/aktivitaet")
|
||||
def aktivitaet():
|
||||
return page("aktivitaet.html", "Aktivität")
|
||||
@@ -798,6 +821,7 @@ def create_app(cfg):
|
||||
ntfy = data.get("ntfy") if isinstance(data.get("ntfy"), 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_retry = data.get("auto_retry") if "auto_retry" 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
|
||||
return jsonify(grotti_config.save_settings(
|
||||
@@ -807,6 +831,7 @@ def create_app(cfg):
|
||||
ntfy=ntfy,
|
||||
alerts=alerts,
|
||||
auto_queue=auto_queue,
|
||||
auto_retry=auto_retry,
|
||||
fqdn=fqdn,
|
||||
watch_dir=watch_dir,
|
||||
))
|
||||
@@ -821,8 +846,17 @@ def create_app(cfg):
|
||||
)
|
||||
return jsonify({"ok": True})
|
||||
|
||||
@app.post("/api/v1/ntfy/test")
|
||||
def ntfy_test():
|
||||
ok = notify.send_ntfy(cfg, "Grotti-Test: ntfy ist verbunden.")
|
||||
if not ok:
|
||||
raise GrottiError(
|
||||
"ntfy nicht erreichbar. Server, Topic und „aktiv“ prüfen.",
|
||||
"ntfy",
|
||||
)
|
||||
return jsonify({"ok": True})
|
||||
|
||||
@app.get("/api/v1/drucker")
|
||||
@app.get("/api/drucker") # veraltet — Alias zu /api/v1/drucker
|
||||
def printers():
|
||||
rows = store.list_printers(conn())
|
||||
clears = store.bed_clear_map(conn())
|
||||
@@ -908,7 +942,18 @@ def create_app(cfg):
|
||||
if other and str(other["id"]) != str(printer_id):
|
||||
raise GrottiError("Diesen Host gibt es schon.", "host")
|
||||
locked = data.get("locked") if "locked" in data else current.get("locked")
|
||||
row = store.update_printer(conn(), printer_id, name, host, family, locked=bool(locked))
|
||||
start_window = data.get("start_window") if "start_window" in data else None
|
||||
if start_window is not None and not isinstance(start_window, dict):
|
||||
raise GrottiError("Zeitfenster muss ein Objekt sein.", "start_window")
|
||||
row = store.update_printer(
|
||||
conn(),
|
||||
printer_id,
|
||||
name,
|
||||
host,
|
||||
family,
|
||||
locked=bool(locked),
|
||||
start_window=start_window,
|
||||
)
|
||||
return jsonify(row)
|
||||
|
||||
@app.delete("/api/v1/drucker/id/<printer_id>")
|
||||
@@ -943,6 +988,40 @@ def create_app(cfg):
|
||||
abort(404)
|
||||
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")
|
||||
def live_preview():
|
||||
host = require_host(request.args.get("host", ""))
|
||||
@@ -976,7 +1055,27 @@ def create_app(cfg):
|
||||
status = data.get("status") or "idee"
|
||||
if status not in PROJECT_STATUS:
|
||||
raise GrottiError("Unbekannter Status.", "status")
|
||||
return jsonify(store.create_project(conn(), name, (data.get("notes") or "").strip(), status)), 201
|
||||
folder = (data.get("folder") or "").strip()
|
||||
if not folder:
|
||||
folder = library.default_project_folder(name)
|
||||
try:
|
||||
if cfg.get("watch_dir"):
|
||||
folder = library.ensure_project_folder(cfg, folder)
|
||||
else:
|
||||
folder = library.normalize_folder(folder)
|
||||
except GrottiError as exc:
|
||||
if exc.code in ("ordner", "watch"):
|
||||
raise GrottiError(
|
||||
"%s — Ordner relativ unter dem Slice-Pfad, z. B. Projekte/%s (kein D:\\…)."
|
||||
% (exc.message.rstrip("."), library.project_slug(name)),
|
||||
exc.code,
|
||||
getattr(exc, "status", 400) or 400,
|
||||
) from exc
|
||||
raise
|
||||
row = store.create_project(
|
||||
conn(), name, (data.get("notes") or "").strip(), status, folder=folder
|
||||
)
|
||||
return jsonify(row), 201
|
||||
|
||||
@app.get("/api/v1/projekte/<int:project_id>")
|
||||
def project_get(project_id):
|
||||
@@ -1000,6 +1099,13 @@ def create_app(cfg):
|
||||
if data["status"] not in PROJECT_STATUS:
|
||||
raise GrottiError("Unbekannter Status.", "status")
|
||||
fields["status"] = data["status"]
|
||||
if "folder" in data:
|
||||
folder = (data.get("folder") or "").strip()
|
||||
if folder and cfg.get("watch_dir"):
|
||||
folder = library.ensure_project_folder(cfg, folder)
|
||||
elif folder:
|
||||
folder = library.normalize_folder(folder)
|
||||
fields["folder"] = folder
|
||||
row = store.update_project(conn(), project_id, fields)
|
||||
if not row:
|
||||
raise GrottiError("Das Projekt fehlt.", "projekt", 404)
|
||||
@@ -1007,15 +1113,95 @@ def create_app(cfg):
|
||||
|
||||
@app.delete("/api/v1/projekte/<int:project_id>")
|
||||
def project_delete(project_id):
|
||||
row = store.get_project(conn(), project_id)
|
||||
if not row:
|
||||
raise GrottiError("Das Projekt fehlt.", "projekt", 404)
|
||||
# ?force=1 löscht den Projektordner inkl. Inhalt
|
||||
force = (request.args.get("force") or "").strip() in ("1", "true", "yes")
|
||||
folder = (row.get("folder") or "").strip()
|
||||
if folder and cfg.get("watch_dir"):
|
||||
library.delete_project_disk_folder(cfg, folder, force=force)
|
||||
if not store.delete_project(conn(), project_id):
|
||||
raise GrottiError("Das Projekt fehlt.", "projekt", 404)
|
||||
return "", 204
|
||||
|
||||
@app.get("/api/v1/projekte/<int:project_id>/assets")
|
||||
def project_assets_list(project_id):
|
||||
row = store.get_project(conn(), project_id)
|
||||
if not row:
|
||||
raise GrottiError("Das Projekt fehlt.", "projekt", 404)
|
||||
folder = (row.get("folder") or "").strip()
|
||||
if not folder:
|
||||
return jsonify([])
|
||||
if not cfg.get("watch_dir"):
|
||||
return jsonify([])
|
||||
return jsonify(library.list_project_assets(cfg, folder))
|
||||
|
||||
@app.post("/api/v1/projekte/<int:project_id>/assets")
|
||||
def project_assets_upload(project_id):
|
||||
row = store.get_project(conn(), project_id)
|
||||
if not row:
|
||||
raise GrottiError("Das Projekt fehlt.", "projekt", 404)
|
||||
folder = (row.get("folder") or "").strip()
|
||||
if not folder:
|
||||
raise GrottiError("Das Projekt hat keinen Ordner.", "ordner")
|
||||
if not cfg.get("watch_dir"):
|
||||
raise GrottiError("Kein Slice-Ordner gesetzt — unter Einstellungen festlegen.", "watch")
|
||||
upload = request.files.get("file")
|
||||
if not upload or not upload.filename:
|
||||
raise GrottiError("Keine Datei angehängt.", "datei")
|
||||
saved = library.save_project_asset(cfg, folder, upload.filename, upload.read())
|
||||
return jsonify(saved), 201
|
||||
|
||||
@app.get("/api/v1/projekte/<int:project_id>/assets/<path:rel>")
|
||||
def project_assets_download(project_id, rel):
|
||||
row = store.get_project(conn(), project_id)
|
||||
if not row:
|
||||
raise GrottiError("Das Projekt fehlt.", "projekt", 404)
|
||||
folder = (row.get("folder") or "").strip()
|
||||
if not folder:
|
||||
raise GrottiError("Das Projekt hat keinen Ordner.", "ordner")
|
||||
path = library.resolve_project_asset(cfg, folder, rel)
|
||||
return send_file(path, as_attachment=True, download_name=path.name)
|
||||
|
||||
@app.delete("/api/v1/projekte/<int:project_id>/assets/<path:rel>")
|
||||
def project_assets_delete(project_id, rel):
|
||||
row = store.get_project(conn(), project_id)
|
||||
if not row:
|
||||
raise GrottiError("Das Projekt fehlt.", "projekt", 404)
|
||||
folder = (row.get("folder") or "").strip()
|
||||
if not folder:
|
||||
raise GrottiError("Das Projekt hat keinen Ordner.", "ordner")
|
||||
library.delete_project_asset(cfg, folder, rel)
|
||||
return "", 204
|
||||
|
||||
@app.get("/api/v1/dateien")
|
||||
def file_list():
|
||||
library.sync_watch(conn(), cfg, force=False)
|
||||
return jsonify(store.list_files(conn(), watch_dir=cfg.get("watch_dir")))
|
||||
|
||||
@app.post("/api/v1/dateien/<int:file_id>/meta")
|
||||
def file_meta_refresh(file_id):
|
||||
"""Eine Datei neu parsen — z. B. paint_index für ACE nach Orca-Deep-Link."""
|
||||
out = library.refresh_one_file(conn(), file_id, watch_dir=cfg.get("watch_dir"))
|
||||
if not out:
|
||||
raise GrottiError("Datei nicht gefunden.", "datei", 404)
|
||||
return jsonify(out)
|
||||
|
||||
@app.get("/api/v1/orca-fokus")
|
||||
def orca_focus():
|
||||
"""Zuletzt von orca_senden gesetzte Datei — offene Tabs schalten um statt neu zu öffnen."""
|
||||
path = Path(cfg["data_dir"]) / "orca-senden-focus.json"
|
||||
if not path.is_file():
|
||||
return jsonify({})
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return jsonify({})
|
||||
if not isinstance(data, dict) or data.get("file_id") is None:
|
||||
return jsonify({})
|
||||
return jsonify(data)
|
||||
|
||||
@app.post("/api/v1/dateien/neu-lesen")
|
||||
def file_rescan():
|
||||
result = library.sync_watch(conn(), cfg, force=True)
|
||||
@@ -1130,13 +1316,7 @@ def create_app(cfg):
|
||||
|
||||
@app.delete("/api/v1/dateien/<int:file_id>")
|
||||
def file_delete(file_id):
|
||||
row = store.get_file_row(conn(), file_id)
|
||||
if not row:
|
||||
raise GrottiError("Die Datei fehlt.", "datei", 404)
|
||||
if row["source"] != "upload":
|
||||
raise GrottiError("Die Datei kommt aus dem Watch-Ordner und bleibt, solange sie dort liegt.", "watch")
|
||||
store.delete_file(conn(), file_id)
|
||||
Path(row["stored_path"]).unlink(missing_ok=True)
|
||||
library.delete_library_file(conn(), cfg, file_id)
|
||||
return "", 204
|
||||
|
||||
@app.post("/api/v1/senden")
|
||||
@@ -1358,6 +1538,62 @@ def create_app(cfg):
|
||||
})
|
||||
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")
|
||||
def print_queue_create():
|
||||
data = body()
|
||||
@@ -1432,8 +1668,8 @@ def create_app(cfg):
|
||||
c = conn()
|
||||
store.set_bed_needs_clear(c, host, False, "quittiert")
|
||||
run_json = None
|
||||
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 any(k in data for k in ("verdict", "verdict_reason", "pieces_ok", "pieces_bad")):
|
||||
if not run_row:
|
||||
raise GrottiError("Kein Lauf zum Bewerten — Protokoll prüfen.", "lauf", 404)
|
||||
try:
|
||||
@@ -1446,12 +1682,42 @@ def create_app(cfg):
|
||||
except ValueError as exc:
|
||||
raise GrottiError(str(exc), "urteil") from exc
|
||||
run_json = store.set_verdict(c, run_row["id"], verdict, reason, ok_n, bad_n)
|
||||
# Live-Lauf schließen: Moonraker-History bleibt oft ohne Endzeit hängen,
|
||||
# sonst bleibt der Druck im Protokoll ewig „in_progress“ trotz Urteil.
|
||||
if run_row and (run_row.get("state") or "") in ("in_progress", "printing", "paused"):
|
||||
closed = store.complete_open_run(c, run_row["id"], state="completed")
|
||||
if closed:
|
||||
try:
|
||||
jobs.apply_deduction(c, closed, cfg["energy"])
|
||||
except Exception as exc:
|
||||
print("Abräumen Abzug %s: %s" % (host, exc), flush=True)
|
||||
row = store.get_run_row(c, closed["id"]) or closed
|
||||
run_json = store.run_out(row)
|
||||
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({
|
||||
"ok": True,
|
||||
"host": host,
|
||||
"needs_clear": False,
|
||||
"message": "Platte quittiert.",
|
||||
"message": "Platte quittiert." + (" Nochmal in der Warteschlange." if reprint else ""),
|
||||
"run": run_json,
|
||||
"reprint": reprint,
|
||||
"verdict_reasons": VERDICT_REASONS,
|
||||
})
|
||||
|
||||
@@ -1486,7 +1752,12 @@ def create_app(cfg):
|
||||
if status == "running" and not data.get("resume"):
|
||||
raise GrottiError("Nur ein gestoppter Ablauf lässt sich fortsetzen.", "status")
|
||||
if status == "waiting" and current_row.get("status") not in ("waiting",):
|
||||
# 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
|
||||
if status == "running" and data.get("resume"):
|
||||
row = current_row
|
||||
@@ -1544,11 +1815,14 @@ def create_app(cfg):
|
||||
@app.get("/api/v1/ereignisse")
|
||||
def events_list():
|
||||
unread = request.args.get("unread") == "1"
|
||||
alerts_only = request.args.get("alerts") == "1"
|
||||
try:
|
||||
limit = max(1, min(1000, int(request.args.get("limit") or 40)))
|
||||
except (TypeError, ValueError):
|
||||
limit = 40
|
||||
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
|
||||
if request.args.get("since"):
|
||||
try:
|
||||
@@ -1564,7 +1838,10 @@ def create_app(cfg):
|
||||
kinds=kinds or None,
|
||||
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")
|
||||
@@ -1576,7 +1853,7 @@ def create_app(cfg):
|
||||
ids = [int(item) for item in ids]
|
||||
except (TypeError, ValueError) as 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})
|
||||
|
||||
@app.post("/api/v1/dateien/von-drucker")
|
||||
@@ -1939,11 +2216,27 @@ def create_app(cfg):
|
||||
|
||||
@app.patch("/api/v1/spulen/<int:spool_id>")
|
||||
def spool_patch(spool_id):
|
||||
row = store.update_spool(conn(), spool_id, spool_fields(body(), partial=True, spool_id=spool_id))
|
||||
data = body()
|
||||
fields = spool_fields(data, partial=True, spool_id=spool_id)
|
||||
if "archived" in data or "archived_at" in data:
|
||||
if data.get("archived") is False or data.get("archived_at") is None:
|
||||
fields["archived_at"] = None
|
||||
elif data.get("archived") is True or data.get("archived_at") in (True, "now"):
|
||||
fields["archived_at"] = "now"
|
||||
row = store.update_spool(conn(), spool_id, fields)
|
||||
if not row:
|
||||
raise GrottiError("Die Spule fehlt.", "spule", 404)
|
||||
return jsonify(row)
|
||||
|
||||
@app.post("/api/v1/spulen/<int:spool_id>/leer")
|
||||
def spool_empty(spool_id):
|
||||
"""Rest 0, Regal, archivieren — wenn der Rest falsch geschätzt war."""
|
||||
row = store.mark_spool_empty(conn(), spool_id)
|
||||
if not row:
|
||||
raise GrottiError("Die Spule fehlt.", "spule", 404)
|
||||
store.add_event(conn(), "", "", "spule", "Spule „%s“ als leer markiert" % row["name"])
|
||||
return jsonify(row)
|
||||
|
||||
@app.post("/api/v1/spulen/<int:spool_id>/einlegen")
|
||||
def spool_load(spool_id):
|
||||
"""Spule an Drucker/Platz legen; vorherige Rolle dort geht ins Regal."""
|
||||
@@ -1953,7 +2246,8 @@ def create_app(cfg):
|
||||
spool = store.get_spool_row(conn(), spool_id)
|
||||
if not spool:
|
||||
raise GrottiError("Die Spule fehlt.", "spule", 404)
|
||||
live_run = store.find_live_run(conn(), host)
|
||||
# Auch offene History-Jobs (Pause/Resume nach Moonraker-Split), nicht nur live:
|
||||
live_run = store.find_active_run(conn(), host)
|
||||
mm_now = float((live_run or {}).get("filament_used_mm") or 0)
|
||||
printing = bool(
|
||||
live_run and (live_run.get("state") or "") in ("in_progress", "printing", "paused")
|
||||
@@ -2002,6 +2296,8 @@ def create_app(cfg):
|
||||
updated = store.update_spool(conn(), spool_id, {"printer_host": host, "ace_slot": slot})
|
||||
if printing:
|
||||
store.open_segment(conn(), live_run["id"], spool_id, slot, mm_now)
|
||||
store.assign_run_spool(conn(), live_run["id"], spool_id)
|
||||
store.update_send_spool(conn(), host, live_run.get("filename") or "", spool_id)
|
||||
store.add_event(
|
||||
conn(),
|
||||
host,
|
||||
@@ -2303,6 +2599,7 @@ def create_app(cfg):
|
||||
host = (request.args.get("host") or "").strip()
|
||||
material = (request.args.get("material") or "").strip()
|
||||
brand = (request.args.get("brand") or "").strip()
|
||||
color = (request.args.get("color") or "").strip()
|
||||
project_id = optional_id(request.args.get("project_id"))
|
||||
return jsonify(store.statistics(
|
||||
conn(),
|
||||
@@ -2311,6 +2608,7 @@ def create_app(cfg):
|
||||
host=host,
|
||||
material=material or "",
|
||||
brand=brand or "",
|
||||
color=color or "",
|
||||
project_id=project_id,
|
||||
))
|
||||
|
||||
@@ -2388,12 +2686,12 @@ def create_app(cfg):
|
||||
finally:
|
||||
events_bus.unsubscribe(q)
|
||||
|
||||
# Kein Connection-Header: waitress verbietet Hop-by-Hop-Header (PEP 3333).
|
||||
return Response(
|
||||
stream_with_context(generate()),
|
||||
mimetype="text/event-stream; charset=utf-8",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -28,13 +28,29 @@ async function api(path, options = {}) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
body = JSON.stringify(options.json);
|
||||
}
|
||||
const response = await fetch("/api/v1" + path, {
|
||||
const timeoutMs = options.timeoutMs != null ? Number(options.timeoutMs) : 20000;
|
||||
const ctrl = options.signal ? null : new AbortController();
|
||||
const timer = ctrl && timeoutMs > 0
|
||||
? setTimeout(() => ctrl.abort(), timeoutMs)
|
||||
: 0;
|
||||
let response;
|
||||
try {
|
||||
response = await fetch("/api/v1" + path, {
|
||||
method,
|
||||
headers,
|
||||
body,
|
||||
cache: "no-store",
|
||||
credentials: "same-origin",
|
||||
signal: options.signal || (ctrl && ctrl.signal),
|
||||
});
|
||||
} catch (error) {
|
||||
if (error && error.name === "AbortError") {
|
||||
throw new Error("Zeitüberschreitung — Server antwortet nicht.");
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
if (response.status === 401) {
|
||||
const here = location.pathname + location.search;
|
||||
if (!here.startsWith("/login") && !here.startsWith("/setup")) {
|
||||
@@ -63,6 +79,34 @@ function withToken(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) {
|
||||
return Number(value || 0).toLocaleString("de-DE", { style: "currency", currency: "EUR" });
|
||||
}
|
||||
@@ -231,9 +275,10 @@ function icon(name, cls) {
|
||||
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 || "";
|
||||
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) {
|
||||
|
||||
@@ -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.
|
||||
@@ -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 |
@@ -1,27 +1,48 @@
|
||||
/* Minimaler Service-Worker: App-Shell-Cache für Offline-Hinweis. */
|
||||
const CACHE = "grotti-shell-v1";
|
||||
const SHELL = ["/", "/static/grotti.css?v=54", "/static/api.js?v=28", "/static/ui.js?v=2"];
|
||||
/* Nur Static-Assets cachen — nie HTML-Navigation abfangen (sonst falsche Seite in Chrome). */
|
||||
const CACHE = "grotti-shell-v3";
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
event.waitUntil(caches.open(CACHE).then((c) => c.addAll(SHELL).catch(() => undefined)));
|
||||
self.skipWaiting();
|
||||
event.waitUntil(
|
||||
caches.open(CACHE).then((cache) =>
|
||||
cache.addAll([
|
||||
"/static/grotti.css?v=62",
|
||||
"/static/api.js?v=32",
|
||||
"/static/ui.js?v=5",
|
||||
"/static/icons.svg",
|
||||
"/static/fonts/InterVariable.woff2",
|
||||
]).catch(() => undefined)
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((keys) =>
|
||||
Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))
|
||||
)
|
||||
).then(() => self.clients.claim())
|
||||
);
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
const req = event.request;
|
||||
if (req.method !== "GET") return;
|
||||
// Dokument-Navigation immer ans Netz — kein Fallback auf „/“.
|
||||
if (req.mode === "navigate") return;
|
||||
const url = new URL(req.url);
|
||||
if (url.origin !== self.location.origin) return;
|
||||
if (url.pathname.startsWith("/api/")) return;
|
||||
if (!url.pathname.startsWith("/static/")) return;
|
||||
|
||||
event.respondWith(
|
||||
fetch(req).catch(() => caches.match(req).then((hit) => hit || caches.match("/")))
|
||||
fetch(req)
|
||||
.then((res) => {
|
||||
if (res.ok) {
|
||||
const copy = res.clone();
|
||||
caches.open(CACHE).then((c) => c.put(req, copy)).catch(() => {});
|
||||
}
|
||||
return res;
|
||||
})
|
||||
.catch(() => caches.match(req))
|
||||
);
|
||||
});
|
||||
@@ -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-bad">Stück schlecht</label><input id="ab-bad" type="number" min="0" step="1" value="1"></div>
|
||||
</div>
|
||||
<label class="check"><input type="checkbox" id="ab-reprint"> Gleich nochmal drucken</label>
|
||||
<div class="actions">
|
||||
<button type="submit" value="ok" class="btn-primary">Quittieren</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 : "",
|
||||
pieces_ok: Number(dlg.querySelector("#ab-ok").value) || 0,
|
||||
pieces_bad: Number(dlg.querySelector("#ab-bad").value) || 0,
|
||||
reprint: !!dlg.querySelector("#ab-reprint").checked,
|
||||
};
|
||||
dlg.remove();
|
||||
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.setConnectionBanner = setConnectionBanner;
|
||||
window.createPoller = createPoller;
|
||||
|
||||
@@ -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> <a href="/aktivitaet">alle →</a></div>
|
||||
</section>
|
||||
@@ -11,13 +11,15 @@
|
||||
</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">
|
||||
<div class="fchips">
|
||||
<button type="button" class="fchip on" data-range="day">Heute</button>
|
||||
<button type="button" class="fchip" data-range="week">7 Tage</button>
|
||||
<button type="button" class="fchip" data-range="all">Alle</button>
|
||||
</div>
|
||||
<div class="filter-bar" id="f-kind" role="group" aria-label="Art"></div>
|
||||
<div class="filter-bar" id="f-host" role="group" aria-label="Drucker"></div>
|
||||
</div>
|
||||
<div class="toolbar" id="f-kind" role="group" aria-label="Art"></div>
|
||||
<div class="toolbar" id="f-host" role="group" aria-label="Drucker"></div>
|
||||
|
||||
<p id="tl-note" class="tiny"></p>
|
||||
<div class="timeline" id="timeline">
|
||||
|
||||
@@ -55,9 +55,9 @@
|
||||
</div>
|
||||
<div class="field"><label for="order-notes">Notiz</label><textarea id="order-notes"></textarea></div>
|
||||
<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="warn" id="order-delete" hidden>Löschen</button>
|
||||
<button type="button" class="btn-danger" id="order-delete" hidden>Löschen</button>
|
||||
</div>
|
||||
</form>
|
||||
<p id="order-note" class="tiny"></p>
|
||||
@@ -81,11 +81,16 @@
|
||||
<div class="actions"><button type="submit">Position hinzufügen</button></div>
|
||||
</form>
|
||||
<details class="section-gap">
|
||||
<summary>Kunde anlegen</summary>
|
||||
<summary>Kunde anlegen / bearbeiten</summary>
|
||||
<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-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>
|
||||
</details>
|
||||
</section>
|
||||
@@ -130,7 +135,14 @@ function paintActuals(order) {
|
||||
const el = document.getElementById("order-actuals");
|
||||
const a = order.actuals;
|
||||
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) {
|
||||
@@ -169,17 +181,55 @@ function renderItems(items) {
|
||||
tr.children[0].textContent = item.file_name || ("#" + (item.file_id || "—"));
|
||||
tr.children[1].textContent = item.qty;
|
||||
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");
|
||||
btn.type = "button";
|
||||
btn.className = "ghost tiny-btn";
|
||||
btn.textContent = "Einreihen";
|
||||
btn.disabled = !item.file_id || !current;
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
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) {
|
||||
if (!current) return;
|
||||
try {
|
||||
@@ -200,19 +250,28 @@ function renderList() {
|
||||
const body = document.getElementById("order-rows");
|
||||
body.innerHTML = "";
|
||||
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;
|
||||
}
|
||||
const selected = document.getElementById("order-id").value;
|
||||
orders.forEach((order) => {
|
||||
const tr = document.createElement("tr");
|
||||
tr.className = "pick";
|
||||
tr.className = "pick" + (selected && String(order.id) === selected ? " on" : "");
|
||||
tr.innerHTML = "<td></td><td></td><td></td>";
|
||||
tr.children[0].textContent = order.title;
|
||||
tr.children[1].textContent = STATUS_LABEL[order.status] || order.status;
|
||||
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.addEventListener("click", async () => {
|
||||
try {
|
||||
const full = await api("/auftraege/" + order.id);
|
||||
fillOrder(full);
|
||||
renderList();
|
||||
} catch (error) {
|
||||
showNote("order-note", error);
|
||||
}
|
||||
});
|
||||
body.appendChild(tr);
|
||||
});
|
||||
@@ -253,7 +312,7 @@ document.getElementById("order-form").addEventListener("submit", async (event) =
|
||||
? await api("/auftraege/" + id, { method: "PATCH", json: payload })
|
||||
: await api("/auftraege", { method: "POST", json: payload });
|
||||
showNote("order-note", null);
|
||||
document.getElementById("order-note").textContent = "Gemerkt.";
|
||||
document.getElementById("order-note").textContent = "Gespeichert.";
|
||||
await loadList();
|
||||
fillOrder(saved);
|
||||
} catch (error) {
|
||||
@@ -296,25 +355,60 @@ document.getElementById("item-form").addEventListener("submit", async (event) =>
|
||||
|
||||
document.getElementById("customer-form").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
const row = await api("/kunden", {
|
||||
method: "POST",
|
||||
json: {
|
||||
const id = document.getElementById("cust-id").value;
|
||||
const payload = {
|
||||
name: document.getElementById("cust-name").value,
|
||||
email: document.getElementById("cust-email").value,
|
||||
},
|
||||
});
|
||||
};
|
||||
try {
|
||||
const row = id
|
||||
? await api("/kunden/" + id, { method: "PATCH", json: payload })
|
||||
: await api("/kunden", { method: "POST", json: payload });
|
||||
if (id) {
|
||||
customers = customers.map((c) => (c.id === row.id ? row : c));
|
||||
} else {
|
||||
customers.push(row);
|
||||
}
|
||||
fillCustomers();
|
||||
document.getElementById("order-customer").value = row.id;
|
||||
document.getElementById("cust-id").value = "";
|
||||
document.getElementById("cust-name").value = "";
|
||||
document.getElementById("cust-email").value = "";
|
||||
toast("Kunde angelegt.");
|
||||
document.getElementById("cust-delete").hidden = true;
|
||||
toast(id ? "Kunde gespeichert." : "Kunde angelegt.");
|
||||
} catch (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));
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -29,6 +29,84 @@ function sourceLabel(source, estimate) {
|
||||
return source || "";
|
||||
}
|
||||
|
||||
const COST_PART_COLORS = {
|
||||
filament: "#0d9488",
|
||||
base: "#94a3b8",
|
||||
nozzle: "#ea580c",
|
||||
bed: "#2563eb",
|
||||
dryer: "#16a34a",
|
||||
};
|
||||
|
||||
function kwhLabel(value) {
|
||||
if (value == null || value === "") return "—";
|
||||
return (Number(value) || 0).toLocaleString("de-DE", { maximumFractionDigits: 3 }) + " kWh";
|
||||
}
|
||||
|
||||
function costPieSvg(parts) {
|
||||
const items = (parts || []).filter((p) => Number(p.cost) > 0);
|
||||
const total = items.reduce((sum, p) => sum + Number(p.cost || 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.cost) / 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 = COST_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="Kostenanteile">${slices}</svg>`;
|
||||
}
|
||||
|
||||
function costBreakdownHtml(energy, filamentCost) {
|
||||
const fil = Number(filamentCost) || 0;
|
||||
const energyParts = (energy && energy.parts) || [];
|
||||
const parts = [];
|
||||
if (fil > 1e-6) {
|
||||
parts.push({ key: "filament", label: "Filament", kwh: null, cost: fil });
|
||||
}
|
||||
energyParts.forEach((p) => parts.push(p));
|
||||
if (!parts.length) return "";
|
||||
const energyEuro = Number((energy && energy.cost) || 0);
|
||||
const totalEuro = fil + energyEuro;
|
||||
const src = energy && energy.source === "ed" ? "Strom aus ED" : (energyParts.length ? "Strom pauschal" : "");
|
||||
const rate = energy && 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 = COST_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("");
|
||||
const sumKwh = energy && energy.kwh != null ? kwhLabel(energy.kwh) : "—";
|
||||
const hint = [src, rate].filter(Boolean).join(" · ");
|
||||
return `<div class="energy-break">
|
||||
<div class="energy-break-chart">${costPieSvg(parts)}</div>
|
||||
<div class="energy-break-table">
|
||||
<div class="lib-head"><h3>Kosten</h3><span class="tiny">${escapeHtml(hint)}</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">${sumKwh}</td><td class="num">${euro(totalEuro)}</td></tr>
|
||||
</tbody>
|
||||
</table></div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function render(data) {
|
||||
const run = data.run;
|
||||
const name = (run.filename || "").split("/").pop() || "ohne Datei";
|
||||
@@ -80,10 +158,12 @@ function render(data) {
|
||||
<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)" : ""}
|
||||
${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>" : ""}
|
||||
${data.project ? "<p class=\"tiny\">Projekt: " + escapeHtml(data.project.name) + "</p>" : ""}
|
||||
</div>
|
||||
${costBreakdownHtml(energy, run.filament_cost)}
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Spulen</h2>
|
||||
|
||||
@@ -18,12 +18,16 @@
|
||||
|
||||
<div class="send-layout">
|
||||
<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>
|
||||
<span class="count" id="lib-count">0</span>
|
||||
<span class="count-pill" id="lib-count">0</span>
|
||||
</div>
|
||||
<div class="lib-tools">
|
||||
<div class="toolbar lib-tools">
|
||||
<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">
|
||||
<option value="">Alle Maschinen</option>
|
||||
<option value="kobra_3">Kobra 3</option>
|
||||
@@ -63,27 +67,26 @@
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<p class="tiny" id="target-hint"></p>
|
||||
</div>
|
||||
|
||||
<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>Start</h3>
|
||||
<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="free">Wenn frei<small>auf diesem Drucker</small></button>
|
||||
<button type="button" data-mode="next">Nächster freier<small>Grotti wählt</small></button>
|
||||
<button type="button" data-mode="this" class="on">Dieser Drucker<small>frei → start, sonst Warteschlange</small></button>
|
||||
<button type="button" data-mode="next">Nächster passender<small>Grotti wählt nach Sorte</small></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<div id="ablauf-preview" class="ablauf-preview"></div>
|
||||
<div class="sweep-preview-wrap" id="sweep-preview-wrap" hidden>
|
||||
@@ -185,7 +188,7 @@ let ablaufDefaults = {
|
||||
check: { max_delta_mm: 3.0, timeout_min: 2, x: 125, y: 125, ref_x: 25, ref_y: 125, z_clear: 20 },
|
||||
};
|
||||
let ablaufSteps = [];
|
||||
const sel = { fileId: null, hosts: new Set(), mode: "now", tab: "send" };
|
||||
const sel = { fileId: null, hosts: new Set(), mode: "this", tab: "send" };
|
||||
let aceHostsKey = "";
|
||||
|
||||
/* ---------- 3D-Pfad ---------- */
|
||||
@@ -198,6 +201,25 @@ function ensureViewer() {
|
||||
return pathViewer;
|
||||
}
|
||||
|
||||
let toolpathGen = 0;
|
||||
|
||||
function scheduleToolpath(file, progress, deferMs) {
|
||||
const gen = ++toolpathGen;
|
||||
const run = () => {
|
||||
if (gen !== toolpathGen) return;
|
||||
showToolpath(file, progress);
|
||||
};
|
||||
if (deferMs > 0) {
|
||||
if (window.requestIdleCallback) {
|
||||
requestIdleCallback(run, { timeout: Math.max(800, deferMs) });
|
||||
} else {
|
||||
setTimeout(run, deferMs);
|
||||
}
|
||||
return;
|
||||
}
|
||||
run();
|
||||
}
|
||||
|
||||
async function showToolpath(file, progress) {
|
||||
const card = document.getElementById("viewer-card");
|
||||
const caption = document.getElementById("viewer-caption");
|
||||
@@ -217,6 +239,7 @@ async function showToolpath(file, progress) {
|
||||
meta.textContent = "Pfad wird gelesen …";
|
||||
try {
|
||||
const data = await api("/dateien/" + file.id + "/pfad?tp=8");
|
||||
if (String(sel.fileId) !== String(file.id)) return;
|
||||
viewer.setPath(data);
|
||||
viewerFileId = file.id;
|
||||
meta.textContent = (data.count || 0).toLocaleString("de-DE") + " Punkte";
|
||||
@@ -258,7 +281,9 @@ function saveSendMemory() {
|
||||
function loadSendMemory() {
|
||||
try {
|
||||
const data = JSON.parse(localStorage.getItem(SEND_KEY) || "{}");
|
||||
if (["now", "free", "next"].includes(data.mode)) sel.mode = data.mode;
|
||||
let mode = data.mode;
|
||||
if (mode === "now" || mode === "free") mode = "this";
|
||||
if (["this", "next"].includes(mode)) sel.mode = mode;
|
||||
if (["send", "ablauf"].includes(data.tab)) sel.tab = data.tab;
|
||||
if (Array.isArray(data.hosts)) sel.hosts = new Set(data.hosts);
|
||||
} catch (error) { /* ignore */ }
|
||||
@@ -305,10 +330,22 @@ function slotTitle(host, slot) {
|
||||
return bits.join(" · ");
|
||||
}
|
||||
|
||||
function paintIndexOf(filament, fallback) {
|
||||
const raw = filament && filament.paint_index;
|
||||
if (raw === 0 || raw === "0") return 0;
|
||||
const n = Number(raw);
|
||||
return Number.isFinite(n) && n >= 0 ? n : fallback;
|
||||
}
|
||||
|
||||
function filamentLabel(filament, index) {
|
||||
const kind = filament.type || "Filament";
|
||||
const paint = paintIndexOf(filament, index);
|
||||
const kind = filament.profile || filament.type || "Filament";
|
||||
const color = filament.color ? " · " + filament.color : "";
|
||||
return "Farbe " + (index + 1) + " · " + kind + color;
|
||||
return "T" + paint + " · " + kind + color;
|
||||
}
|
||||
|
||||
function canStartNow(p) {
|
||||
return isFree(p) && !p.needs_clear;
|
||||
}
|
||||
|
||||
function spoolsOnHost(host) {
|
||||
@@ -322,6 +359,19 @@ function spoolWarnText() {
|
||||
const maps = collectAceMaps();
|
||||
const hosts = selectedHosts();
|
||||
const fileTypes = ((file.filaments || []).map((f) => (f.type || "").toLowerCase()).filter(Boolean));
|
||||
const fileProfiles = ((file.filaments || []).map((f) => (f.profile || "").toLowerCase()).filter(Boolean));
|
||||
function profileOk(spoolRows) {
|
||||
if (!fileProfiles.length) return true;
|
||||
const names = spoolRows.map((s) => (s.name || "").toLowerCase()).filter(Boolean);
|
||||
if (!names.length) return true;
|
||||
return fileProfiles.every((want) => names.some((n) => {
|
||||
if (n === want) return true;
|
||||
if (!n.startsWith(want) || /[a-z0-9]/i.test(n.charAt(want.length) || "")) return false;
|
||||
const rest = n.slice(want.length).replace(/^[\s·\-_|/]+/, "");
|
||||
const first = (rest.split(/\s+/)[0] || "").toLowerCase();
|
||||
return !["meta", "matte", "matt", "silk", "plus", "hf", "cf"].includes(first);
|
||||
}));
|
||||
}
|
||||
for (const host of hosts) {
|
||||
const name = (printers.find((p) => p.host === host) || {}).name || host;
|
||||
const mapping = maps[host] || [];
|
||||
@@ -330,18 +380,23 @@ function spoolWarnText() {
|
||||
let left = 0;
|
||||
let any = false;
|
||||
const types = [];
|
||||
const mappedSpools = [];
|
||||
mapping.forEach((item) => {
|
||||
const slot = Number(item.ams_index) + 1;
|
||||
const spool = onHost.find((s) => Number(s.ace_slot) === slot);
|
||||
if (spool) {
|
||||
any = true;
|
||||
left += Number(spool.remaining_g) || 0;
|
||||
mappedSpools.push(spool);
|
||||
if (spool.material_type) types.push(spool.material_type.toLowerCase());
|
||||
}
|
||||
});
|
||||
if (!any) return name + ": keine Grotti-Spule auf den gewählten ACE-Plätzen — Abzug entfällt.";
|
||||
if (left + 0.5 < need) return "ACE auf " + name + ": nur " + grams(left) + ", Datei braucht " + grams(need) + ".";
|
||||
if (fileTypes.length && types.length && !fileTypes.some((t) => types.includes(t))) {
|
||||
if (!profileOk(mappedSpools)) {
|
||||
return name + ": Sorte passt nicht (Datei " + fileProfiles.join("/") + ").";
|
||||
}
|
||||
if (!fileProfiles.length && fileTypes.length && types.length && !fileTypes.some((t) => types.includes(t))) {
|
||||
return name + ": Material passt nicht (Datei " + fileTypes.join("/") + ", ACE " + types.join("/") + ").";
|
||||
}
|
||||
continue;
|
||||
@@ -355,8 +410,11 @@ function spoolWarnText() {
|
||||
if (left + 0.5 < need) {
|
||||
return name + ": nur " + grams(left) + ", Datei braucht " + grams(need) + ".";
|
||||
}
|
||||
if (!profileOk(external)) {
|
||||
return name + ": Sorte passt nicht (Datei " + fileProfiles.join("/") + ").";
|
||||
}
|
||||
const types = external.map((s) => (s.material_type || "").toLowerCase()).filter(Boolean);
|
||||
if (fileTypes.length && types.length && !fileTypes.some((t) => types.includes(t))) {
|
||||
if (!fileProfiles.length && fileTypes.length && types.length && !fileTypes.some((t) => types.includes(t))) {
|
||||
return name + ": Material passt nicht (Datei " + fileTypes.join("/") + ", Drucker " + types.join("/") + ").";
|
||||
}
|
||||
}
|
||||
@@ -428,16 +486,17 @@ function renderAceMaps(force) {
|
||||
title.className = "ace-printer-name";
|
||||
title.textContent = printer.name;
|
||||
block.appendChild(title);
|
||||
paints.forEach((filament, paintIndex) => {
|
||||
paints.forEach((filament, uiIndex) => {
|
||||
const paint = paintIndexOf(filament, uiIndex);
|
||||
const row = document.createElement("label");
|
||||
row.className = "ace-row";
|
||||
const swatch = document.createElement("span");
|
||||
swatch.className = "ace-swatch";
|
||||
if (filament.color) swatch.style.background = filament.color;
|
||||
const text = document.createElement("span");
|
||||
text.textContent = filamentLabel(filament, paintIndex);
|
||||
text.textContent = filamentLabel(filament, uiIndex);
|
||||
const select = document.createElement("select");
|
||||
select.dataset.paint = String(paintIndex);
|
||||
select.dataset.paint = String(paint);
|
||||
select.innerHTML = '<option value="">Platz wählen</option>';
|
||||
printer.slots.forEach((slot) => {
|
||||
const option = document.createElement("option");
|
||||
@@ -448,16 +507,30 @@ function renderAceMaps(force) {
|
||||
const hex = (spool && spool.colors && spool.colors[0] && spool.colors[0].hex) || slot.color || "";
|
||||
if (hex) option.style.borderLeft = "4px solid " + hex;
|
||||
const spoolType = (spool && spool.material_type) || slot.type || "";
|
||||
// Vorauswahl: gleiche Sorte (Grotti-Spule bevorzugt), sonst Platz in Reihenfolge
|
||||
if (!select.dataset.picked && filament.type && spoolType
|
||||
const spoolName = ((spool && spool.name) || "").toLowerCase();
|
||||
const wantProfile = (filament.profile || "").toLowerCase();
|
||||
// Vorauswahl: Katalog-Sorte exakt (Meta ≠ Matte), sonst grober Typ
|
||||
let profileHit = false;
|
||||
if (wantProfile && spoolName) {
|
||||
if (spoolName === wantProfile) profileHit = true;
|
||||
else if (spoolName.startsWith(wantProfile) && !/[a-z0-9]/i.test(spoolName.charAt(wantProfile.length) || "")) {
|
||||
const rest = spoolName.slice(wantProfile.length).replace(/^[\s·\-_|/]+/, "");
|
||||
const first = (rest.split(/\s+/)[0] || "").toLowerCase();
|
||||
if (!["meta", "matte", "matt", "silk", "plus", "hf", "cf"].includes(first)) profileHit = true;
|
||||
}
|
||||
}
|
||||
if (!select.dataset.picked && profileHit) {
|
||||
option.selected = true;
|
||||
select.dataset.picked = "1";
|
||||
} else if (!select.dataset.picked && !wantProfile && filament.type && spoolType
|
||||
&& filament.type.toLowerCase() === String(spoolType).toLowerCase()) {
|
||||
option.selected = true;
|
||||
select.dataset.picked = "1";
|
||||
}
|
||||
select.appendChild(option);
|
||||
});
|
||||
if (!select.dataset.picked && printer.slots[paintIndex]) {
|
||||
select.value = String(printer.slots[paintIndex].index);
|
||||
if (!select.dataset.picked && printer.slots[uiIndex]) {
|
||||
select.value = String(printer.slots[uiIndex].index);
|
||||
}
|
||||
select.addEventListener("change", updateSpoolWarn);
|
||||
row.appendChild(swatch);
|
||||
@@ -634,8 +707,9 @@ function fillFolderFilter() {
|
||||
}
|
||||
|
||||
function moveSelectHtml(file) {
|
||||
if (!hasWatchDir || file.source !== "watch") return "";
|
||||
const current = folderKey(file);
|
||||
if (!hasWatchDir) return "";
|
||||
if (file.source !== "watch" && file.source !== "upload") return "";
|
||||
const current = file.source === "upload" ? "" : folderKey(file);
|
||||
const opts = [
|
||||
`<option value="" selected disabled>Verschieben…</option>`,
|
||||
`<option value="__root__">Wurzel</option>`,
|
||||
@@ -697,8 +771,8 @@ function sliceOf(file) {
|
||||
}
|
||||
|
||||
function materialText(file) {
|
||||
const types = [...new Set(((file && file.filaments) || []).map((f) => f.type).filter(Boolean))];
|
||||
return types.join("+");
|
||||
const labels = [...new Set(((file && file.filaments) || []).map((f) => f.profile || f.type).filter(Boolean))];
|
||||
return labels.join("+");
|
||||
}
|
||||
|
||||
function layerHeightText(file) {
|
||||
@@ -753,8 +827,7 @@ function thumbHtml(file) {
|
||||
|
||||
function libItemHtml(file) {
|
||||
const project = projects.find((p) => String(p.id) === String(file.project_id));
|
||||
const del = file.source === "upload"
|
||||
? `<button type="button" class="icon-btn" data-delete="${file.id}" title="Hochgeladene Datei löschen">${icon("x")}</button>` : "";
|
||||
const del = `<button type="button" class="icon-btn" data-delete="${file.id}" title="Datei löschen">${icon("trash")}</button>`;
|
||||
return `<div class="lib-item ${String(file.id) === String(sel.fileId) ? "on" : ""}" role="button" tabindex="0" data-file="${file.id}">
|
||||
${thumbHtml(file)}
|
||||
<span style="min-width:0"><span class="t" title="${escapeHtml(file.name)}">${escapeHtml(file.name)}</span><span class="m">${alwaysMeta(file, project)}</span></span>
|
||||
@@ -878,17 +951,25 @@ function updateSendButton() {
|
||||
let note = "";
|
||||
if (!file) problem = "Erst eine Datei wählen.";
|
||||
else if (file.kind === "3mf") problem = "Die 3MF hat keinen G-code. Mit G-code slicen.";
|
||||
if (sel.mode === "now") {
|
||||
if (sel.mode === "this") {
|
||||
if (!problem && !chosen.length) problem = "Einen oder mehrere Drucker wählen.";
|
||||
const busy = chosen.filter((p) => !isFree(p));
|
||||
if (!problem && busy.length) note = namesText(busy) + " " + (busy.length === 1 ? "ist" : "sind") + " nicht frei. „Wenn frei“ reiht stattdessen ein.";
|
||||
text = chosen.length ? "Jetzt starten auf " + namesText(chosen) : "Jetzt starten";
|
||||
} else if (sel.mode === "free") {
|
||||
text = chosen.length ? "Einreihen für " + namesText(chosen) : "Einreihen: nächster freier " + familyOfSelection();
|
||||
note = chosen.length ? "Startet von selbst, sobald der Drucker bereit und die Platte frei ist." : "Ohne Drucker: der nächste freie passende übernimmt.";
|
||||
const ready = chosen.filter(canStartNow);
|
||||
const later = chosen.filter((p) => !canStartNow(p));
|
||||
if (ready.length && !later.length) {
|
||||
text = "Jetzt starten auf " + namesText(ready);
|
||||
note = "";
|
||||
} else if (!ready.length && later.length) {
|
||||
text = "Einreihen für " + namesText(later);
|
||||
note = "Startet, sobald der Drucker bereit und die Platte frei ist.";
|
||||
} else if (ready.length && later.length) {
|
||||
text = "Starten / einreihen auf " + namesText(chosen);
|
||||
note = namesText(ready) + " sofort, " + namesText(later) + " in die Warteschlange.";
|
||||
} else {
|
||||
text = "Einreihen: nächster freier " + familyOfSelection();
|
||||
note = "Grotti nimmt den ersten freien Drucker mit passender Maschine.";
|
||||
text = "Starten";
|
||||
}
|
||||
} else {
|
||||
text = "Einreihen: nächster passender " + familyOfSelection();
|
||||
note = "Grotti wählt den nächsten freien Drucker mit passender Sorte (z. B. PLA Meta ≠ Matte).";
|
||||
}
|
||||
const mismatch = chosen.filter((p) => familyMismatch(p, file));
|
||||
if (!problem && mismatch.length && !document.getElementById("send-override").checked) {
|
||||
@@ -898,7 +979,7 @@ function updateSendButton() {
|
||||
button.disabled = !!problem || !!sendPoll;
|
||||
hint.textContent = problem || note;
|
||||
hint.classList.toggle("bad", !!problem);
|
||||
targetHint.textContent = sel.mode === "next" && sel.tab === "send" ? "Ziel wählt Grotti." : chosen.length > 1 ? "Je Drucker ein eigener Auftrag." : "";
|
||||
targetHint.textContent = sel.mode === "next" && sel.tab === "send" ? "Ziel wählt Grotti nach Sorte." : chosen.length > 1 ? "Je Drucker ein eigener Auftrag." : "";
|
||||
}
|
||||
|
||||
function updateAblaufButton() {
|
||||
@@ -967,7 +1048,7 @@ function selectFile(id) {
|
||||
}
|
||||
document.querySelectorAll("#lib-list .lib-item").forEach((n) => n.classList.toggle("on", n.dataset.file === String(id)));
|
||||
refreshFlow();
|
||||
showToolpath(file, 0);
|
||||
scheduleToolpath(file, 0, 0);
|
||||
if (window.matchMedia("(max-width: 1080px)").matches) {
|
||||
document.querySelector(".send-panel").scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
@@ -1011,19 +1092,72 @@ async function load() {
|
||||
renderLibrary();
|
||||
renderTabs();
|
||||
refreshFlow();
|
||||
const file = selectedFile();
|
||||
if (file) showToolpath(file, 0);
|
||||
// Deep-Link von Orca /orca_senden: Senden in den Blick, Optionen offen für ACE
|
||||
if (new URLSearchParams(location.search).get("file") && file) {
|
||||
let file = selectedFile();
|
||||
// Orca/Anycubic: Metadaten oft ohne paint_index → ACE zeigt T0/T1 statt T1/T10
|
||||
if (file && file.id && filamentsNeedPaintIndex(file.filaments)) {
|
||||
try {
|
||||
const fresh = await api("/dateien/" + file.id + "/meta", { method: "POST" });
|
||||
if (fresh && fresh.id) {
|
||||
const idx = files.findIndex((row) => String(row.id) === String(fresh.id));
|
||||
if (idx >= 0) files[idx] = fresh;
|
||||
else files.unshift(fresh);
|
||||
file = selectedFile();
|
||||
renderLibrary();
|
||||
refreshFlow();
|
||||
}
|
||||
} catch (error) { /* manuell: Ordner neu lesen */ }
|
||||
}
|
||||
const q = new URLSearchParams(location.search);
|
||||
const deepLink = !!(q.get("file") || q.get("orca"));
|
||||
// Deep-Link / Orca-App: ACE/Senden zuerst — 3D-Pfad friert sonst den Tab ein
|
||||
if (file) scheduleToolpath(file, 0, deepLink ? 900 : 0);
|
||||
if (deepLink) {
|
||||
sel.tab = "send";
|
||||
renderTabs();
|
||||
const opts = document.getElementById("options");
|
||||
if (opts) opts.open = true;
|
||||
const panel = document.querySelector(".send-panel");
|
||||
if (panel) panel.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
if (file) lastOrcaFocusId = String(file.id);
|
||||
if (q.get("orca")) pullOrcaFocus();
|
||||
}
|
||||
}
|
||||
|
||||
let lastOrcaFocusId = null;
|
||||
|
||||
async function pullOrcaFocus() {
|
||||
try {
|
||||
const data = await api("/orca-fokus");
|
||||
if (!data || data.file_id == null) return;
|
||||
const id = String(data.file_id);
|
||||
const age = Date.now() / 1000 - Number(data.ts || 0);
|
||||
if (age > 180) return;
|
||||
const already = id === String(sel.fileId) && selectedFile();
|
||||
if (id === lastOrcaFocusId && already) return;
|
||||
const switched = lastOrcaFocusId != null && id !== lastOrcaFocusId;
|
||||
lastOrcaFocusId = id;
|
||||
if (already) return;
|
||||
let file = files.find((row) => String(row.id) === id);
|
||||
if (!file) {
|
||||
files = await api("/dateien");
|
||||
renderLibrary();
|
||||
file = files.find((row) => String(row.id) === id);
|
||||
}
|
||||
if (file) {
|
||||
selectFile(file.id);
|
||||
sel.tab = "send";
|
||||
renderTabs();
|
||||
if (switched) toast("Neue Slice-Datei ausgewählt.", "good");
|
||||
}
|
||||
} catch (error) { /* nächster Tick */ }
|
||||
}
|
||||
|
||||
function filamentsNeedPaintIndex(filaments) {
|
||||
const rows = filaments || [];
|
||||
if (rows.length < 2) return false;
|
||||
return rows.some((item) => !item || item.paint_index == null);
|
||||
}
|
||||
|
||||
async function refreshPrinters() {
|
||||
if (document.hidden) return;
|
||||
try {
|
||||
@@ -1101,17 +1235,12 @@ async function watchJobs(ids) {
|
||||
await tick();
|
||||
}
|
||||
|
||||
async function doSend() {
|
||||
const button = document.getElementById("send-go");
|
||||
const whenFree = sel.mode !== "now";
|
||||
button.disabled = true;
|
||||
document.getElementById("send-note").textContent = "";
|
||||
try {
|
||||
const payload = await api("/senden", {
|
||||
async function postSenden(hosts, whenFree) {
|
||||
return api("/senden", {
|
||||
method: "POST",
|
||||
json: {
|
||||
file_id: Number(sel.fileId),
|
||||
hosts: selectedHosts(),
|
||||
hosts,
|
||||
confirm: true,
|
||||
override_machine: document.getElementById("send-override").checked,
|
||||
bed_leveling: document.getElementById("send-bed-level").checked,
|
||||
@@ -1119,14 +1248,23 @@ async function doSend() {
|
||||
when_free: whenFree,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function doSend() {
|
||||
const button = document.getElementById("send-go");
|
||||
button.disabled = true;
|
||||
document.getElementById("send-note").textContent = "";
|
||||
try {
|
||||
const projectId = document.getElementById("send-project")?.value;
|
||||
if (projectId && sel.fileId) {
|
||||
try {
|
||||
await api("/dateien/" + sel.fileId, { method: "PATCH", json: { project_id: Number(projectId) } });
|
||||
} catch (error) { /* Projektbindung optional */ }
|
||||
}
|
||||
|
||||
if (sel.mode === "next") {
|
||||
const payload = await postSenden([], true);
|
||||
const jobs = payload.jobs || [];
|
||||
if (whenFree || payload.mode === "when_free") {
|
||||
document.getElementById("send-progress").hidden = true;
|
||||
toast((jobs.length > 1 ? jobs.length + " Aufträge" : "Auftrag") + " eingereiht. Steht unter „Wartet“.", "good");
|
||||
updateSendButton();
|
||||
@@ -1134,9 +1272,35 @@ async function doSend() {
|
||||
refreshPrinters();
|
||||
return;
|
||||
}
|
||||
showJobsProgress(jobs);
|
||||
|
||||
const chosen = selectedPrinters();
|
||||
const ready = chosen.filter(canStartNow).map((p) => p.host);
|
||||
const later = chosen.filter((p) => !canStartNow(p)).map((p) => p.host);
|
||||
let queued = 0;
|
||||
let nowJobs = [];
|
||||
if (later.length) {
|
||||
const payload = await postSenden(later, true);
|
||||
queued += (payload.jobs || []).length;
|
||||
}
|
||||
if (ready.length) {
|
||||
const payload = await postSenden(ready, false);
|
||||
nowJobs = payload.jobs || [];
|
||||
}
|
||||
if (queued && !nowJobs.length) {
|
||||
document.getElementById("send-progress").hidden = true;
|
||||
toast((queued > 1 ? queued + " Aufträge" : "Auftrag") + " eingereiht. Steht unter „Wartet“.", "good");
|
||||
updateSendButton();
|
||||
if (typeof refreshPrintQueue === "function") refreshPrintQueue();
|
||||
refreshPrinters();
|
||||
return;
|
||||
}
|
||||
if (queued && nowJobs.length) {
|
||||
toast(queued + " eingereiht, " + nowJobs.length + " startet jetzt.", "good");
|
||||
if (typeof refreshPrintQueue === "function") refreshPrintQueue();
|
||||
}
|
||||
showJobsProgress(nowJobs);
|
||||
if (typeof refreshSendQueue === "function") refreshSendQueue();
|
||||
await watchJobs(jobs.map((job) => job.id));
|
||||
await watchJobs(nowJobs.map((job) => job.id));
|
||||
} catch (error) {
|
||||
updateSendButton();
|
||||
toast(error.message || String(error), "bad");
|
||||
@@ -1441,7 +1605,7 @@ document.getElementById("targets").addEventListener("click", (event) => {
|
||||
toast("Abläufe gehen nur auf Kobra 3.", "bad");
|
||||
return;
|
||||
}
|
||||
if (sel.tab === "send" && sel.mode === "next") sel.mode = "free";
|
||||
if (sel.tab === "send" && sel.mode === "next") sel.mode = "this";
|
||||
if (sel.hosts.has(host)) sel.hosts.delete(host);
|
||||
else sel.hosts.add(host);
|
||||
saveSendMemory();
|
||||
@@ -1475,19 +1639,11 @@ document.getElementById("send-bed-level").addEventListener("change", updateOptio
|
||||
document.getElementById("send-override").addEventListener("change", () => { refreshFlow(); });
|
||||
document.getElementById("send-project")?.addEventListener("change", updateOptionsSummary);
|
||||
|
||||
document.getElementById("send-go").addEventListener("click", (event) => {
|
||||
const button = event.currentTarget;
|
||||
const warn = spoolWarnText();
|
||||
if (sel.mode === "now") {
|
||||
armButton(button, warn ? "Warnung — trotzdem starten?" : "Wirklich starten? Nochmal klicken", doSend);
|
||||
} else if (warn) {
|
||||
armButton(button, "Warnung — trotzdem einreihen?", doSend);
|
||||
} else {
|
||||
document.getElementById("send-go").addEventListener("click", () => {
|
||||
doSend();
|
||||
}
|
||||
});
|
||||
document.getElementById("ablauf-go").addEventListener("click", (event) => {
|
||||
armButton(event.currentTarget, "Ablauf wirklich einreihen? Nochmal klicken", doAblauf);
|
||||
document.getElementById("ablauf-go").addEventListener("click", () => {
|
||||
doAblauf();
|
||||
});
|
||||
|
||||
document.getElementById("ablauf-add-print").addEventListener("click", () => addStep(defaultPrintStep()));
|
||||
@@ -1587,5 +1743,6 @@ loadSendMemory();
|
||||
loadAblaufMemory();
|
||||
load().catch((error) => toast(error.message || String(error), "bad"));
|
||||
setInterval(refreshPrinters, 5000);
|
||||
setInterval(pullOrcaFocus, 2000);
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -13,15 +13,14 @@
|
||||
<div class="flow-diagram">
|
||||
<span class="step done">Datei</span><span class="step-sep"></span>
|
||||
<span class="step done">Drucker</span><span class="step-sep"></span>
|
||||
<span class="step now">Jetzt · Wenn frei · Nächster freier</span><span class="step-sep"></span>
|
||||
<span class="step now">Dieser Drucker · Nächster passender</span><span class="step-sep"></span>
|
||||
<span class="step">Druck</span><span class="step-sep"></span>
|
||||
<span class="step">Platte räumen</span>
|
||||
</div>
|
||||
<ol>
|
||||
<li><strong>Datei wählen.</strong> Unter <a href="/dateien">Dateien</a> links in der Bibliothek. Der Slice-Ordner wird beobachtet, Hochladen geht per Knopf oder Ziehen.</li>
|
||||
<li><strong>Drucker wählen.</strong> Die Kacheln zeigen live, was jeder Drucker gerade tut. Passt die Maschine nicht zur Datei, ist die Kachel blass.</li>
|
||||
<li><strong>Wann.</strong> <em>Jetzt</em> startet sofort. <em>Wenn frei</em> reiht für diesen Drucker ein. <em>Nächster freier</em> lässt Grotti den ersten freien passenden Drucker nehmen.</li>
|
||||
<li><strong>Starten.</strong> „Jetzt“ will zwei Klicks. Der zweite ist die Bestätigung.</li>
|
||||
<li><strong>Start.</strong> <em>Dieser Drucker</em>: frei → sofort, sonst Warteschlange. <em>Nächster passender</em>: Grotti wählt nach Maschine und Sorte (PLA Meta ≠ Matte).</li>
|
||||
<li><strong>Während des Drucks</strong> zeigt die <a href="/">Wand</a> Fortschritt, Fertig-Uhrzeit und Temperaturen. Ein Klick auf die Karte öffnet das Cockpit mit Kamera (falls vorhanden), Steuerung, Bettnetz und 3D-Pfad. Die Seitenleiste zeigt immer, was läuft und was wartet.</li>
|
||||
<li><strong>Danach</strong> ist die Platte belegt. Ein wartender Auftrag startet erst, wenn auf der Karte <em>Platte geräumt</em> gedrückt wurde, oder ein Ablauf sie selbst freischiebt.</li>
|
||||
</ol>
|
||||
@@ -49,7 +48,8 @@
|
||||
<dl class="terms">
|
||||
<dt>Auftrag</dt><dd>Ein Eintrag in der Warteschlange: eine Datei für einen Drucker oder für den nächsten freien.</dd>
|
||||
<dt>Ablauf</dt><dd>Auftrag mit mehreren Schritten und Wiederholungen. Nur Kobra 3.</dd>
|
||||
<dt>Wenn frei</dt><dd>Startet, sobald der Drucker bereit und die Platte geräumt ist.</dd>
|
||||
<dt>Dieser Drucker</dt><dd>Frei und Platte klar → sofort; sonst Warteschlange für genau diesen Drucker.</dd>
|
||||
<dt>Nächster passender</dt><dd>Grotti claimt den nächsten freien Drucker mit passender Katalog-Sorte und Restfilament.</dd>
|
||||
<dt>Platte belegt</dt><dd>Nach jedem Druck gesetzt. Blockiert den nächsten Auftrag auf diesem Drucker.</dd>
|
||||
<dt>Alert</dt><dd>Wichtiges Ereignis (Fertig, Fehler, Abbruch, offline). Steht in der Seitenleiste und geht auf Wunsch an Telegram.</dd>
|
||||
<dt>Ereignis</dt><dd>Jeder Eintrag unter Aktivität. Ablauf-Schritte und Starts sind Ereignisse, aber keine Alerts.</dd>
|
||||
|
||||
@@ -5,20 +5,45 @@
|
||||
<h1>Einstellungen</h1>
|
||||
<p class="tiny">Drucker, Benachrichtigungen und Energiekosten.</p>
|
||||
</div>
|
||||
<p class="settings-save-hint" id="settings-autosave-hint" aria-live="polite"></p>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<p class="hint">Nur auf diesem Gerät / in diesem Browser. Anderer PC oder Inkognito bleibt unberührt.</p>
|
||||
<div class="field" style="max-width:16rem">
|
||||
<div class="grid-2 narrow-fields">
|
||||
<div class="field">
|
||||
<label for="display-mode">Anzeige</label>
|
||||
<select id="display-mode">
|
||||
<option value="desktop">Desktop</option>
|
||||
<option value="shelf">Regal (Touch)</option>
|
||||
</select>
|
||||
</div>
|
||||
<p class="hint">Regal: größere Bedienelemente, kompakte Navigation — ideal für ~1024×860 Touch am Regal. Shortcut: <code>?shelf=1</code></p>
|
||||
<div class="field" style="max-width:16rem; margin-top:0.85rem">
|
||||
<div class="field">
|
||||
<label for="theme-mode">Darstellung</label>
|
||||
<select id="theme-mode">
|
||||
<option value="dark">Dunkel</option>
|
||||
<option value="light">Hell</option>
|
||||
<option value="system">System</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="tz-mode">Zeitzone</label>
|
||||
<select id="tz-mode">
|
||||
<option value="auto">Browser (automatisch)</option>
|
||||
@@ -28,19 +53,28 @@
|
||||
<option value="UTC">UTC</option>
|
||||
</select>
|
||||
</div>
|
||||
<p class="hint">Für Protokoll, Bericht und Aktivitätszeiten. Nur dieser Browser.</p>
|
||||
<div class="field">
|
||||
<label for="camera-mode">Kamera</label>
|
||||
<select id="camera-mode">
|
||||
<option value="snapshot">Snapshot (schonend)</option>
|
||||
<option value="stream">Stream (flüssig)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint">Regal: größere Bedienelemente — Shortcut <code>?shelf=1</code>. Darstellung, Zeitzone und Kamera nur dieser Browser. Stream hält eine Verbindung je sichtbarer Kamera.</p>
|
||||
<p id="display-note" class="ok" hidden>Gespeichert — gilt sofort.</p>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<section class="card" id="sec-auto">
|
||||
<h2>Automatik</h2>
|
||||
<label class="check"><input type="checkbox" id="auto-queue"> Warteschlange „wenn frei“ und Abläufe automatisch starten</label>
|
||||
<p class="hint">Aus = keine neuen Starts. Laufende Abläufe und gesperrte Drucker bleiben unberührt.</p>
|
||||
<div class="actions"><button type="button" id="auto-save">Speichern</button></div>
|
||||
<p id="auto-note"></p>
|
||||
<label class="check"><input type="checkbox" id="auto-retry"> Automatisch einmal nochmal versuchen bei Fehler</label>
|
||||
<p class="hint">Aus = keine neuen Starts. Laufende Abläufe und gesperrte Drucker bleiben unberührt. Änderungen speichern sich selbst.</p>
|
||||
<div class="actions"><button type="button" id="auto-save" hidden>Speichern</button></div>
|
||||
<p id="auto-note" class="settings-save-hint"></p>
|
||||
</section>
|
||||
|
||||
<section class="card admin-only">
|
||||
<section class="card admin-only" id="sec-slice">
|
||||
<h2>Slice-Ordner</h2>
|
||||
<p class="hint">Ordner, den Grotti für die Bibliothek beobachtet (Orca-Export, „Ordner neu lesen“). Leer = nur manueller Upload. Standard: Ordner <code>slice</code> im Grotti-Repo.</p>
|
||||
<div class="field" style="max-width:36rem">
|
||||
@@ -48,13 +82,13 @@
|
||||
<input id="watch-dir" type="text" autocomplete="off" placeholder="D:\Projekte\Grotti\slice" spellcheck="false">
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="button" id="watch-save">Speichern</button>
|
||||
<button type="button" id="watch-save" hidden>Speichern</button>
|
||||
<button type="button" class="ghost" id="watch-default">Standardordner</button>
|
||||
</div>
|
||||
<p id="watch-note"></p>
|
||||
</section>
|
||||
|
||||
<section class="card admin-only">
|
||||
<section class="card admin-only" id="sec-fqdn">
|
||||
<h2>Öffentliche Adresse</h2>
|
||||
<p class="hint">FQDN bzw. Basis-URL für QR-Codes auf Spulen-Etiketten. Leer = Host der aktuellen Anfrage (z. B. localhost).</p>
|
||||
<div class="field" style="max-width:28rem">
|
||||
@@ -62,20 +96,15 @@
|
||||
<input id="fqdn" placeholder="https://grotti.example.de oder grotti.lan" autocomplete="off">
|
||||
</div>
|
||||
<p class="hint">Ohne Schema wird <code>https://</code> ergänzt. Mit Port: <code>http://10.10.1.5:8770</code>.</p>
|
||||
<div class="actions"><button type="button" id="fqdn-save">Speichern</button></div>
|
||||
<div class="actions"><button type="button" id="fqdn-save" hidden>Speichern</button></div>
|
||||
<p id="fqdn-note"></p>
|
||||
</section>
|
||||
|
||||
<section class="card admin-only">
|
||||
<section class="card admin-only" id="sec-drucker">
|
||||
<h2>Drucker</h2>
|
||||
<p class="hint">Gesperrt = kein neuer „wenn frei“-Start auf diesem Drucker.</p>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>Host</th><th>Familie</th><th>Gesperrt</th><th></th></tr></thead>
|
||||
<tbody id="printer-rows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<form id="printer-add" class="grid-2" style="margin-top:1rem">
|
||||
<p class="hint">Gesperrt = kein neuer „wenn frei“-Start. Zeitfenster begrenzt Starts (z. B. Nachtstrom). Tippen öffnet den Dialog.</p>
|
||||
<div id="printer-list" class="printer-edit-cards"></div>
|
||||
<form id="printer-add" class="grid-2 section-gap">
|
||||
<div class="field">
|
||||
<label for="add-name">Name</label>
|
||||
<input id="add-name" required placeholder="z. B. Kobra 3 Werkstatt">
|
||||
@@ -97,7 +126,7 @@
|
||||
<p id="printer-note"></p>
|
||||
</section>
|
||||
|
||||
<section class="card admin-only">
|
||||
<section class="card admin-only" id="sec-telegram">
|
||||
<h2>Telegram</h2>
|
||||
<label class="check"><input type="checkbox" id="tg-enabled"> Benachrichtigungen aktiv</label>
|
||||
<div class="grid-2" style="margin-top:0.75rem">
|
||||
@@ -120,13 +149,13 @@
|
||||
</div>
|
||||
<p class="hint">Ereignisse stehen immer unter <a href="/aktivitaet">Aktivität</a>. Die Häkchen steuern nur Telegram.</p>
|
||||
<div class="actions">
|
||||
<button type="button" id="tg-save">Speichern</button>
|
||||
<button type="button" id="tg-save" hidden>Speichern</button>
|
||||
<button type="button" class="ghost" id="tg-test">Test senden</button>
|
||||
</div>
|
||||
<p id="tg-note"></p>
|
||||
</section>
|
||||
|
||||
<section class="card admin-only">
|
||||
<section class="card admin-only" id="sec-ntfy">
|
||||
<h2>ntfy</h2>
|
||||
<label class="check"><input type="checkbox" id="ntfy-enabled"> Push über ntfy</label>
|
||||
<div class="grid-2" style="margin-top:0.75rem">
|
||||
@@ -143,12 +172,15 @@
|
||||
<input id="ntfy-token" type="password" autocomplete="off" placeholder="Bearer-Token">
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint">Gleiche Meldungen wie Telegram — unabhängig abschaltbar.</p>
|
||||
<div class="actions"><button type="button" id="ntfy-save">Speichern</button></div>
|
||||
<p class="hint">Gleiche Meldungen wie Telegram — unabhängig abschaltbar. Push in der ntfy-App auf dem Handy.</p>
|
||||
<div class="actions">
|
||||
<button type="button" id="ntfy-save" hidden>Speichern</button>
|
||||
<button type="button" class="ghost" id="ntfy-test">Test senden</button>
|
||||
</div>
|
||||
<p id="ntfy-note"></p>
|
||||
</section>
|
||||
|
||||
<section class="card admin-only">
|
||||
<section class="card admin-only" id="sec-backup">
|
||||
<h2>Backup</h2>
|
||||
<p class="hint">PostgreSQL-Dump und Datenordner (ohne toolpath/ und alte Backups).</p>
|
||||
<div class="actions">
|
||||
@@ -158,7 +190,7 @@
|
||||
<p id="backup-note"></p>
|
||||
</section>
|
||||
|
||||
<section class="card admin-only">
|
||||
<section class="card admin-only" id="sec-energie">
|
||||
<h2>Energie</h2>
|
||||
<p class="hint">Kosten im Protokoll. Mit Düse-/Bett-Watt und ED-Proben schätzt Grotti den Verbrauch; sonst Dauer × Grundlast. Filament braucht eine Spule.</p>
|
||||
<div class="grid-2">
|
||||
@@ -171,13 +203,21 @@
|
||||
<div class="field"><label for="en-waste">Ausschuss %</label><input id="en-waste" type="number" step="0.1"></div>
|
||||
<div class="field"><label for="en-util">Auslastung %</label><input id="en-util" type="number" step="1"></div>
|
||||
</div>
|
||||
<p class="hint">Grundlast = Elektronik/Motion (nicht ganzer Drucker). Düse/Bett 0 = nur pauschal. Typisch Kobra: Düse ~60 W, Bett ~200–300 W.</p>
|
||||
<h3 class="section-gap">HT / NT-Tarif</h3>
|
||||
<label class="check"><input type="checkbox" id="en-tariff"> Unterschiedliche Preise für Tag und Nacht</label>
|
||||
<div class="grid-2" id="en-tariff-fields" hidden>
|
||||
<div class="field"><label for="en-ht">HT € / kWh</label><input id="en-ht" type="number" step="0.01"></div>
|
||||
<div class="field"><label for="en-nt">NT € / kWh</label><input id="en-nt" type="number" step="0.01"></div>
|
||||
<div class="field"><label for="en-nt-from">NT ab</label><input id="en-nt-from" type="time" value="22:00"></div>
|
||||
<div class="field"><label for="en-nt-to">NT bis</label><input id="en-nt-to" type="time" value="06:00"></div>
|
||||
</div>
|
||||
<p class="hint">Grundlast = Elektronik/Motion. Düse/Bett 0 = nur pauschal. Mit HT/NT gewichtet Grotti die Stromkosten nach Uhrzeit.</p>
|
||||
<p id="en-ed-hint" class="hint" hidden></p>
|
||||
<div class="actions"><button type="button" id="en-save">Speichern</button></div>
|
||||
<div class="actions"><button type="button" id="en-save" hidden>Speichern</button></div>
|
||||
<p id="en-note"></p>
|
||||
</section>
|
||||
|
||||
<section class="card admin-only">
|
||||
<section class="card admin-only" id="sec-users">
|
||||
<h2>Benutzer</h2>
|
||||
<p class="hint">Rollen: <strong>Admin</strong> (alles) und <strong>Operator</strong> (Drucker, Queue, Material — keine Benutzerverwaltung).</p>
|
||||
<div class="table-wrap">
|
||||
@@ -198,7 +238,7 @@
|
||||
<p id="user-note"></p>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<section class="card" id="sec-tokens">
|
||||
<h2>API-Tokens</h2>
|
||||
<p class="hint">Für App, Home-Automation oder MCP. Klartext erscheint nur einmal bei der Erstellung. Authorization: <code>Bearer grt_…</code></p>
|
||||
<div class="table-wrap">
|
||||
@@ -217,8 +257,40 @@
|
||||
</form>
|
||||
<p id="token-note"></p>
|
||||
<p class="ok" id="token-once" hidden></p>
|
||||
<p class="hint" id="legacy-token-hint" hidden>In der Config steht noch ein Shared-Token — der gilt weiter als Admin. Besser auf Benutzer-Tokens umstellen und <code>token</code> in der Config leeren.</p>
|
||||
<p class="hint" id="legacy-token-hint" hidden>In der Config steht noch ein Shared-<code>token</code> — der wird seit 3.1 nicht mehr akzeptiert. Bitte leeren und API-Tokens (<code>grt_…</code>) nutzen.</p>
|
||||
</section>
|
||||
|
||||
</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 %}
|
||||
{% block script %}
|
||||
<script>
|
||||
@@ -234,62 +306,65 @@ if (!isAdmin) {
|
||||
|
||||
/* showNote aus api.js: Inline-Hinweis + Toast */
|
||||
|
||||
function windowFrom(row) {
|
||||
const sw = row.start_window || {};
|
||||
const allowed = Array.isArray(sw.allowed) ? sw.allowed : [];
|
||||
const first = allowed[0] || {};
|
||||
return {
|
||||
enabled: allowed.length > 0,
|
||||
from: first.from || "22:00",
|
||||
to: first.to || "06:00",
|
||||
maxHours: sw.max_hours_overnight || "",
|
||||
};
|
||||
}
|
||||
|
||||
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) {
|
||||
const body = document.getElementById("printer-rows");
|
||||
body.innerHTML = "";
|
||||
printerRowsCache = rows || [];
|
||||
const root = document.getElementById("printer-list");
|
||||
root.innerHTML = "";
|
||||
if (!rows.length) {
|
||||
body.innerHTML = '<tr><td class="empty" colspan="5">Keine Drucker.</td></tr>';
|
||||
root.innerHTML = '<div class="empty-state"><svg class="ico"><use href="#i-printer"/></svg><p>Keine Drucker.</p></div>';
|
||||
return;
|
||||
}
|
||||
rows.forEach((row) => {
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML = `<td><input data-f="name"></td><td><input data-f="host"></td><td><select data-f="family">
|
||||
<option value="kobra_3">Kobra 3</option>
|
||||
<option value="kobra_s1">Kobra S1</option>
|
||||
<option value="">—</option>
|
||||
</select></td><td><label class="check"><input type="checkbox" data-f="locked"></label></td><td></td>`;
|
||||
tr.querySelector('[data-f="name"]').value = row.name || "";
|
||||
tr.querySelector('[data-f="host"]').value = row.host || "";
|
||||
tr.querySelector('[data-f="family"]').value = row.family || "";
|
||||
tr.querySelector('[data-f="locked"]').checked = !!row.locked;
|
||||
const save = document.createElement("button");
|
||||
save.type = "button";
|
||||
save.textContent = "Speichern";
|
||||
save.addEventListener("click", async () => {
|
||||
try {
|
||||
await api("/drucker/id/" + row.id, {
|
||||
method: "PATCH",
|
||||
json: {
|
||||
name: tr.querySelector('[data-f="name"]').value,
|
||||
host: tr.querySelector('[data-f="host"]').value,
|
||||
family: tr.querySelector('[data-f="family"]').value,
|
||||
locked: tr.querySelector('[data-f="locked"]').checked,
|
||||
},
|
||||
});
|
||||
showNote("printer-note", null, "Gespeichert: " + tr.querySelector('[data-f="name"]').value);
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
tr.children[4].appendChild(save);
|
||||
tr.children[4].appendChild(del);
|
||||
body.appendChild(tr);
|
||||
const win = windowFrom(row);
|
||||
const card = document.createElement("div");
|
||||
card.className = "printer-edit-card";
|
||||
card.innerHTML = `
|
||||
<div class="grow">
|
||||
<strong></strong>
|
||||
<div class="tiny"></div>
|
||||
</div>
|
||||
<button type="button" class="ghost">Bearbeiten</button>`;
|
||||
card.querySelector("strong").textContent = row.name || row.host;
|
||||
card.querySelector(".tiny").textContent = [
|
||||
row.host,
|
||||
families[row.family] || row.family || "",
|
||||
row.locked ? "gesperrt" : "",
|
||||
win.enabled ? ("Fenster " + win.from + "–" + win.to) : "",
|
||||
].filter(Boolean).join(" · ");
|
||||
card.querySelector("button").addEventListener("click", () => openPrinterDialog(row));
|
||||
card.addEventListener("dblclick", () => openPrinterDialog(row));
|
||||
root.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -391,6 +466,8 @@ async function load() {
|
||||
]);
|
||||
if (isAdmin) renderPrinters(printers);
|
||||
document.getElementById("auto-queue").checked = settings.auto_queue !== false;
|
||||
const ar = document.getElementById("auto-retry");
|
||||
if (ar) ar.checked = settings.auto_retry !== false;
|
||||
const watchDir = document.getElementById("watch-dir");
|
||||
if (watchDir) {
|
||||
watchDir.value = settings.watch_dir || "";
|
||||
@@ -430,6 +507,13 @@ async function load() {
|
||||
document.getElementById("en-dryh").value = en.dry_hours ?? 0;
|
||||
document.getElementById("en-waste").value = en.waste_percent ?? 0;
|
||||
document.getElementById("en-util").value = en.utilization ?? 50;
|
||||
const tariff = en.tariff || null;
|
||||
document.getElementById("en-tariff").checked = !!tariff;
|
||||
document.getElementById("en-tariff-fields").hidden = !tariff;
|
||||
document.getElementById("en-ht").value = (tariff && tariff.ht_euro) ?? (en.euro_per_kwh ?? 0.35);
|
||||
document.getElementById("en-nt").value = (tariff && tariff.nt_euro) ?? (en.euro_per_kwh ?? 0.35);
|
||||
document.getElementById("en-nt-from").value = (tariff && tariff.nt_from) || "22:00";
|
||||
document.getElementById("en-nt-to").value = (tariff && tariff.nt_to) || "06:00";
|
||||
updateEnergyEdHint();
|
||||
}
|
||||
const legacy = document.getElementById("legacy-token-hint");
|
||||
@@ -482,7 +566,10 @@ document.getElementById("auto-save").addEventListener("click", async () => {
|
||||
try {
|
||||
await api("/einstellungen", {
|
||||
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(
|
||||
"auto-note",
|
||||
@@ -634,6 +721,19 @@ document.getElementById("tg-test").addEventListener("click", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("ntfy-test")?.addEventListener("click", async () => {
|
||||
try {
|
||||
await api("/ntfy/test", { method: "POST", json: {} });
|
||||
showNote("ntfy-note", null, "Testnachricht gesendet.");
|
||||
} catch (error) {
|
||||
showNote("ntfy-note", error);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("en-tariff")?.addEventListener("change", (event) => {
|
||||
document.getElementById("en-tariff-fields").hidden = !event.target.checked;
|
||||
});
|
||||
|
||||
function updateEnergyEdHint() {
|
||||
const hint = document.getElementById("en-ed-hint");
|
||||
if (!hint) return;
|
||||
@@ -657,10 +757,7 @@ document.getElementById("en-save").addEventListener("click", async () => {
|
||||
try {
|
||||
const nozzle = Number(document.getElementById("en-nozzle").value) || 0;
|
||||
const bed = Number(document.getElementById("en-bed").value) || 0;
|
||||
await api("/einstellungen", {
|
||||
method: "PATCH",
|
||||
json: {
|
||||
energy: {
|
||||
const energy = {
|
||||
watts: Number(document.getElementById("en-watts").value),
|
||||
nozzle_watts: nozzle,
|
||||
bed_watts: bed,
|
||||
@@ -669,9 +766,16 @@ document.getElementById("en-save").addEventListener("click", async () => {
|
||||
dry_hours: Number(document.getElementById("en-dryh").value),
|
||||
waste_percent: Number(document.getElementById("en-waste").value),
|
||||
utilization: Number(document.getElementById("en-util").value),
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
if (document.getElementById("en-tariff").checked) {
|
||||
energy.tariff = {
|
||||
ht_euro: Number(document.getElementById("en-ht").value),
|
||||
nt_euro: Number(document.getElementById("en-nt").value),
|
||||
nt_from: document.getElementById("en-nt-from").value || "22:00",
|
||||
nt_to: document.getElementById("en-nt-to").value || "06:00",
|
||||
};
|
||||
}
|
||||
await api("/einstellungen", { method: "PATCH", json: { energy } });
|
||||
updateEnergyEdHint();
|
||||
if (nozzle <= 0 || bed <= 0) {
|
||||
const note = document.getElementById("en-note");
|
||||
@@ -688,6 +792,13 @@ document.getElementById("en-save").addEventListener("click", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
function flashDisplayNote() {
|
||||
const note = document.getElementById("display-note");
|
||||
if (!note) return;
|
||||
note.hidden = false;
|
||||
setTimeout(() => { note.hidden = true; }, 2500);
|
||||
}
|
||||
|
||||
(function initDisplayMode() {
|
||||
const sel = document.getElementById("display-mode");
|
||||
if (!sel) return;
|
||||
@@ -703,11 +814,28 @@ document.getElementById("en-save").addEventListener("click", async () => {
|
||||
document.documentElement.classList.toggle("shelf", sel.value === "shelf");
|
||||
document.body.classList.toggle("shelf", sel.value === "shelf");
|
||||
}
|
||||
const note = document.getElementById("display-note");
|
||||
if (note) {
|
||||
note.hidden = false;
|
||||
setTimeout(() => { note.hidden = true; }, 2500);
|
||||
flashDisplayNote();
|
||||
});
|
||||
})();
|
||||
|
||||
(function initThemeMode() {
|
||||
const sel = document.getElementById("theme-mode");
|
||||
if (!sel) return;
|
||||
try {
|
||||
const value = localStorage.getItem("grotti-theme") || "dark";
|
||||
sel.value = ["dark", "light", "system"].includes(value) ? value : "dark";
|
||||
} catch (error) {
|
||||
sel.value = "dark";
|
||||
}
|
||||
sel.addEventListener("change", () => {
|
||||
try { localStorage.setItem("grotti-theme", sel.value); } catch (error) {}
|
||||
if (typeof applyTheme === "function") applyTheme(sel.value);
|
||||
else {
|
||||
const light = sel.value === "light" || (sel.value === "system" && window.matchMedia("(prefers-color-scheme: light)").matches);
|
||||
document.documentElement.dataset.theme = light ? "light" : "dark";
|
||||
document.documentElement.style.colorScheme = light ? "light" : "dark";
|
||||
}
|
||||
flashDisplayNote();
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -731,6 +859,121 @@ document.getElementById("en-save").addEventListener("click", async () => {
|
||||
});
|
||||
})();
|
||||
|
||||
(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));
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -5,18 +5,44 @@
|
||||
<h1>Kameras</h1>
|
||||
<p class="tiny">Livebilder aller Drucker.</p>
|
||||
</div>
|
||||
<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 class="camera-grid" id="grid"></div>
|
||||
{% endblock %}
|
||||
{% block script %}
|
||||
<script>
|
||||
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) {
|
||||
const state = shownState(printer);
|
||||
const busy = state === "printing" || state === "paused";
|
||||
const bits = [STATE_LABELS[state] || state];
|
||||
const bits = [chipHtml(state, STATE_LABELS[state] || state)];
|
||||
if (busy) {
|
||||
const pct = Math.round((printer.progress || 0) * 100);
|
||||
bits.push(pct + " %");
|
||||
@@ -25,7 +51,7 @@ function metaLine(printer) {
|
||||
else if (hours(printer.remain)) bits.push("Rest " + hours(printer.remain));
|
||||
}
|
||||
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(" · ");
|
||||
}
|
||||
|
||||
@@ -61,15 +87,16 @@ async function refresh() {
|
||||
return;
|
||||
}
|
||||
if (grid.querySelector(".empty-state")) grid.innerHTML = "";
|
||||
const streaming = cameraMode() === "stream";
|
||||
const seen = new Set();
|
||||
for (const printer of printers) {
|
||||
seen.add(printer.host);
|
||||
const tile = ensureTile(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(".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);
|
||||
const img = tile.querySelector('[data-role="cam"]');
|
||||
const empty = tile.querySelector('[data-role="cam-empty"]');
|
||||
@@ -78,10 +105,13 @@ async function refresh() {
|
||||
img.hidden = true;
|
||||
empty.hidden = false;
|
||||
empty.innerHTML = icon("camera") + "<span>Keine Kamera</span>";
|
||||
img.dataset.srcKey = "";
|
||||
continue;
|
||||
}
|
||||
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.hidden = true;
|
||||
empty.hidden = false;
|
||||
|
||||
@@ -9,11 +9,27 @@
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='brand/favicon-32.png') }}">
|
||||
<link rel="icon" type="image/png" sizes="512x512" href="{{ url_for('static', filename='brand/favicon.png') }}">
|
||||
<link rel="apple-touch-icon" href="{{ url_for('static', filename='brand/apple-touch-icon.png') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=54">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=68">
|
||||
<link rel="manifest" href="{{ url_for('static', filename='manifest.webmanifest') }}">
|
||||
<meta name="theme-color" content="#0a0a0c">
|
||||
<meta name="theme-color" content="#0a0a0c" id="theme-color-meta">
|
||||
<script>
|
||||
(function () {
|
||||
function applyThemeEarly(mode) {
|
||||
var light = mode === "light";
|
||||
if (mode === "system") {
|
||||
try { light = window.matchMedia("(prefers-color-scheme: light)").matches; } catch (e) { light = false; }
|
||||
}
|
||||
document.documentElement.dataset.theme = light ? "light" : "dark";
|
||||
document.documentElement.style.colorScheme = light ? "light" : "dark";
|
||||
var meta = document.getElementById("theme-color-meta");
|
||||
if (meta) meta.content = light ? "#f4f4f6" : "#0a0a0c";
|
||||
}
|
||||
window.applyTheme = function (mode) {
|
||||
try { if (mode) localStorage.setItem("grotti-theme", mode); } catch (e) {}
|
||||
var m = mode;
|
||||
try { m = m || localStorage.getItem("grotti-theme") || "dark"; } catch (e) { m = "dark"; }
|
||||
applyThemeEarly(m);
|
||||
};
|
||||
try {
|
||||
if (!localStorage.getItem("grotti-display") && localStorage.getItem("farm-display")) {
|
||||
localStorage.setItem("grotti-display", localStorage.getItem("farm-display"));
|
||||
@@ -35,120 +51,80 @@
|
||||
if (nav === "1" || (nav == null && localStorage.getItem("grotti-display") === "shelf")) {
|
||||
document.documentElement.classList.add("nav-collapsed");
|
||||
}
|
||||
applyThemeEarly(localStorage.getItem("grotti-theme") || "dark");
|
||||
} catch (e) {}
|
||||
try {
|
||||
window.matchMedia("(prefers-color-scheme: light)").addEventListener("change", function () {
|
||||
var mode = "dark";
|
||||
try { mode = localStorage.getItem("grotti-theme") || "dark"; } catch (err) {}
|
||||
if (mode === "system") applyThemeEarly("system");
|
||||
});
|
||||
} catch (e) {}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body class="app-shell">
|
||||
<div id="conn-banner" class="conn-banner" hidden role="status"></div>
|
||||
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
|
||||
<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>
|
||||
{{ icons_sprite }}
|
||||
<aside class="sidebar">
|
||||
<div class="brand-row">
|
||||
<a class="brand" href="/" title="Grotti">
|
||||
<img class="brand-mark" src="{{ url_for('static', filename='brand/logo.png') }}" alt="" width="28" height="28" decoding="async">
|
||||
<span class="brand-name">Grotti</span>
|
||||
</a>
|
||||
<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>
|
||||
<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="/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="/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>
|
||||
<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="/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>
|
||||
<span class="nav-label nav-desk">Verwalten</span>
|
||||
<a href="/projekte" class="{{ 'on' if page == 'projekte' else '' }} nav-desk" title="Projekte"><svg class="ico"><use href="#i-folder"/></svg><span>Projekte</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>
|
||||
<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> <a href="/aktivitaet">alle →</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>
|
||||
<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>
|
||||
{% block body %}{% endblock %}
|
||||
</main>
|
||||
@@ -156,30 +132,55 @@
|
||||
<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="/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="/aktivitaet" class="{{ 'on' if page == 'aktivitaet' else '' }}"><svg class="ico"><use href="#i-activity"/></svg><span>Aktivität</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="/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>
|
||||
</nav>
|
||||
<dialog id="mobile-more-sheet" class="mobile-more-sheet">
|
||||
<nav aria-label="Weitere Seiten">
|
||||
<a href="/kameras">Kameras</a>
|
||||
<a href="/auftraege">Aufträge</a>
|
||||
<a href="/projekte">Projekte</a>
|
||||
<a href="/protokoll">Protokoll</a>
|
||||
<a href="/statistik">Statistik</a>
|
||||
<a href="/aktivitaet">Aktivität</a>
|
||||
<a href="/material">Material</a>
|
||||
<a href="/wartung">Wartung</a>
|
||||
<a href="/einstellungen">Einstellungen</a>
|
||||
<a href="/doku">Doku</a>
|
||||
</nav>
|
||||
<form method="dialog"><button class="ghost">Schließen</button></form>
|
||||
{% 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>
|
||||
</dialog>
|
||||
<script src="{{ url_for('static', filename='api.js') }}?v=28"></script>
|
||||
<script src="{{ url_for('static', filename='ui.js') }}?v=2"></script>
|
||||
<script src="{{ url_for('static', filename='api.js') }}?v=32"></script>
|
||||
<script src="{{ url_for('static', filename='ui.js') }}?v=5"></script>
|
||||
<script>
|
||||
document.getElementById("logout-btn")?.addEventListener("click", async () => {
|
||||
async function doLogout() {
|
||||
try { await api("/auth/logout", { method: "POST", json: {} }); } catch (e) {}
|
||||
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() {
|
||||
const shelf = document.documentElement.classList.contains("shelf");
|
||||
const collapsed = document.documentElement.classList.contains("nav-collapsed");
|
||||
@@ -218,6 +219,11 @@ function setDisplayMode(mode) {
|
||||
document.getElementById("nav-toggle")?.addEventListener("click", () => {
|
||||
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() {
|
||||
let closed = [];
|
||||
@@ -238,33 +244,74 @@ function queueTarget(job) {
|
||||
|
||||
async function cancelQueueItem(job, button) {
|
||||
const running = job.status === "running";
|
||||
const armed = running
|
||||
? (job.is_ablauf ? "Ablauf stoppen?" : "Auftrag stoppen?")
|
||||
: "Entfernen?";
|
||||
const go = async () => {
|
||||
if (button) button.disabled = true;
|
||||
const prev = { status: job.status, priority: job.priority };
|
||||
try {
|
||||
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) {
|
||||
toast(error.message, "bad");
|
||||
}
|
||||
refreshPrintQueue();
|
||||
refreshJetzt();
|
||||
if (typeof window.onQueueChanged === "function") window.onQueueChanged();
|
||||
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();
|
||||
}
|
||||
window.cancelQueueItem = cancelQueueItem;
|
||||
|
||||
function ablaufStepFrac(job) {
|
||||
if (!job || !job.is_ablauf || !job.step_labels || !job.step_labels.length) return "";
|
||||
const idx = Number(job.display_step_index != null ? job.display_step_index : job.step_index) || 0;
|
||||
return (idx + 1) + "/" + job.step_labels.length;
|
||||
function setListHtml(el, html) {
|
||||
if (!el) return;
|
||||
if (el._html === html) return;
|
||||
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 finish = remain > 0 ? finishClock(remain) : "";
|
||||
const bits = [name];
|
||||
@@ -276,8 +323,9 @@ function runLineHtml({ name, progress, remain, filename, tipExtra, cancelId }) {
|
||||
remain > 0 ? "noch " + hours(remain) : "",
|
||||
tipExtra ? "Schritt " + tipExtra : "",
|
||||
].filter(Boolean).join(" · ");
|
||||
const href = host ? "/#" + encodeURIComponent(host) : "/";
|
||||
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-text">${escapeHtml(bits.join(" · "))}</span>
|
||||
</a>
|
||||
@@ -285,214 +333,74 @@ function runLineHtml({ name, progress, remain, filename, tipExtra, cancelId }) {
|
||||
</li>`;
|
||||
}
|
||||
|
||||
function runItemHtml(job) {
|
||||
return runLineHtml({
|
||||
name: queueTarget(job),
|
||||
progress: job.progress,
|
||||
remain: job.remain,
|
||||
filename: job.filename,
|
||||
tipExtra: ablaufStepFrac(job),
|
||||
cancelId: job.id,
|
||||
});
|
||||
function actionItemHtml(action) {
|
||||
const kind = action.kind || "";
|
||||
const cls = kind === "queue_error" || kind === "bedarf" || kind === "wartung" || kind === "auftrag" || kind === "filament_pause" ? "error" : "";
|
||||
let tools = "";
|
||||
if (kind === "clear" && action.host) {
|
||||
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>`;
|
||||
} else if (kind === "queue_error" && action.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 {
|
||||
tools = `<span class="queue-tools"><button type="button" class="icon-btn danger" data-dismiss-q="${action.id}" title="Ausblenden">${icon("x")}</button></span>`;
|
||||
}
|
||||
|
||||
function waitItemHtml(job, index, total) {
|
||||
const isError = job.status === "error";
|
||||
const label = job.is_ablauf ? "Ablauf · " + jobLabel(job.filename) : jobLabel(job.filename);
|
||||
const rep = job.is_ablauf && (job.repeat_total || 1) > 1 ? job.repeat_total + "×" : "";
|
||||
return `<li class="queue-item ${isError ? "error" : ""}">
|
||||
} else if (kind === "waiting" && action.id) {
|
||||
tools = `<span class="queue-tools"><button type="button" class="icon-btn danger" data-cancel-q="${action.id}" title="Entfernen">${icon("x")}</button></span>`;
|
||||
}
|
||||
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">
|
||||
<span class="grow"><span class="queue-name" title="${escapeHtml(job.filename)}">${escapeHtml(label)}</span></span>
|
||||
<span class="queue-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>
|
||||
<a class="queue-link grow" href="${escapeHtml(href)}"><span class="queue-name">${escapeHtml(action.title || name)}</span></a>
|
||||
${tools}
|
||||
</div>
|
||||
<span class="queue-sub">${isError ? "fehlgeschlagen · " : "→ "}${escapeHtml(queueTarget(job))}${rep ? " · " + rep : ""}</span>
|
||||
${isError ? `<span class="queue-msg">${escapeHtml(job.message || "")}</span>` : ""}
|
||||
<span class="queue-msg">${escapeHtml([name !== action.title ? name : "", detail].filter(Boolean).join(" · "))}</span>
|
||||
</li>`;
|
||||
}
|
||||
|
||||
function directItemHtml(p) {
|
||||
return runLineHtml({
|
||||
name: p.name || p.host,
|
||||
progress: p.progress,
|
||||
remain: p.remain,
|
||||
filename: p.filename,
|
||||
function renderLiveChips({ actionItems, running, activeSends, alertCount, etaText }) {
|
||||
const box = document.getElementById("live-chips");
|
||||
if (!box) return;
|
||||
const bits = [];
|
||||
if (etaText) {
|
||||
bits.push(`<span class="live-eta">${escapeHtml(etaText)}</span>`);
|
||||
}
|
||||
if (actionItems.length) {
|
||||
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>`);
|
||||
}
|
||||
running.forEach((p) => {
|
||||
const pct = Math.max(0, Math.min(100, Math.round((Number(p.progress) || 0) * 100)));
|
||||
const finish = p.remain > 0 ? finishClock(p.remain) : "";
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
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 refreshTasks() {
|
||||
let items;
|
||||
try { items = await api("/aufgaben"); } catch (error) { return; }
|
||||
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) => {
|
||||
function bindLivePanelActions(panel) {
|
||||
if (!panel) return;
|
||||
panel.querySelectorAll("[data-cleared]").forEach((btn) => {
|
||||
btn.onclick = async () => {
|
||||
const host = btn.dataset.cleared;
|
||||
const payload = await promptAbraeumen(btn.closest("li")?.querySelector(".queue-name")?.textContent || host);
|
||||
@@ -502,64 +410,242 @@ async function refreshTasks() {
|
||||
await api("/drucker/" + encodeURIComponent(host) + "/abraeumen", { method: "POST", json: payload });
|
||||
toast("Platte frei.");
|
||||
} catch (error) { toast(error.message, "bad"); }
|
||||
refreshTasks();
|
||||
refreshJetzt();
|
||||
if (typeof window.onQueueChanged === "function") window.onQueueChanged();
|
||||
btn.disabled = false;
|
||||
};
|
||||
});
|
||||
}
|
||||
document.getElementById("mobile-more")?.addEventListener("click", () => {
|
||||
document.getElementById("mobile-more-sheet")?.showModal();
|
||||
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();
|
||||
});
|
||||
});
|
||||
if ("serviceWorker" in navigator) {
|
||||
navigator.serviceWorker.register("/static/sw.js").catch(() => {});
|
||||
}
|
||||
|
||||
document.getElementById("events-clear").addEventListener("click", async (event) => {
|
||||
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() {
|
||||
const sheet = document.getElementById("mobile-more-sheet");
|
||||
const btn = document.getElementById("mobile-more");
|
||||
if (!sheet || !btn) return;
|
||||
btn.addEventListener("click", () => {
|
||||
if (typeof sheet.showModal === "function") sheet.showModal();
|
||||
});
|
||||
sheet.addEventListener("click", (event) => {
|
||||
const link = event.target.closest("a[href]");
|
||||
if (!link) return;
|
||||
event.preventDefault();
|
||||
const href = link.getAttribute("href");
|
||||
sheet.close();
|
||||
if (href) location.href = href;
|
||||
});
|
||||
sheet.addEventListener("close", () => {
|
||||
btn.setAttribute("aria-expanded", "false");
|
||||
});
|
||||
btn.addEventListener("click", () => btn.setAttribute("aria-expanded", "true"));
|
||||
})();
|
||||
if ("serviceWorker" in navigator) {
|
||||
navigator.serviceWorker.register("/static/sw.js", { updateViaCache: "none" }).catch(() => {});
|
||||
}
|
||||
|
||||
document.getElementById("events-clear")?.addEventListener("click", async (event) => {
|
||||
event.preventDefault();
|
||||
try { await api("/ereignisse/gelesen", { method: "POST", json: {} }); } catch (error) { /* still */ }
|
||||
refreshEvents();
|
||||
refreshJetzt();
|
||||
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.refreshSendQueue = refreshSendQueue;
|
||||
refreshSendQueue();
|
||||
refreshPrintQueue();
|
||||
refreshEvents();
|
||||
refreshTasks();
|
||||
window.refreshBedarf = refreshBedarf;
|
||||
window.refreshTasks = refreshTasks;
|
||||
window.refreshJetzt = refreshJetzt;
|
||||
refreshJetzt();
|
||||
|
||||
(function liveUpdates() {
|
||||
let streamLive = false;
|
||||
const sseKinds = {
|
||||
queue: () => refreshPrintQueue(),
|
||||
senden: () => refreshSendQueue(),
|
||||
ereignis: () => refreshEvents(),
|
||||
aufgaben: () => refreshTasks(),
|
||||
queue: () => refreshJetzt(),
|
||||
senden: () => refreshJetzt(),
|
||||
ereignis: () => refreshJetzt(),
|
||||
aufgaben: () => refreshJetzt(),
|
||||
drucker: () => {
|
||||
refreshPrintQueue();
|
||||
refreshTasks();
|
||||
refreshJetzt();
|
||||
if (typeof window.onDruckerLive === "function") window.onDruckerLive();
|
||||
},
|
||||
};
|
||||
|
||||
function whenStreamDown(fn) {
|
||||
return async () => {
|
||||
if (streamLive) return;
|
||||
await fn();
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof EventSource !== "undefined") {
|
||||
let es;
|
||||
let retryMs = 2000;
|
||||
const retryMax = 60000;
|
||||
let hadStream = false;
|
||||
let retryTimer = 0;
|
||||
|
||||
function connectStream() {
|
||||
if (es) {
|
||||
try { es.close(); } catch (e) {}
|
||||
}
|
||||
es = new EventSource("/api/v1/stream", { withCredentials: true });
|
||||
es.addEventListener("open", () => {
|
||||
streamLive = true;
|
||||
retryMs = 2000;
|
||||
if (hadStream) setConnectionBanner(true, "Live-Verbindung wieder da.");
|
||||
hadStream = true;
|
||||
});
|
||||
es.addEventListener("error", () => {
|
||||
es.close();
|
||||
streamLive = false;
|
||||
try { es.close(); } catch (e) {}
|
||||
setConnectionBanner(false, "Live-Verbindung unterbrochen — Fallback-Polling läuft.");
|
||||
setTimeout(connectStream, retryMs);
|
||||
clearTimeout(retryTimer);
|
||||
retryTimer = setTimeout(connectStream, retryMs);
|
||||
retryMs = Math.min(retryMax, retryMs * 2);
|
||||
});
|
||||
Object.keys(sseKinds).forEach((kind) => {
|
||||
@@ -568,12 +654,16 @@ window.refreshTasks = refreshTasks;
|
||||
es.addEventListener("ping", () => {});
|
||||
}
|
||||
connectStream();
|
||||
window.addEventListener("pagehide", () => {
|
||||
streamLive = false;
|
||||
clearTimeout(retryTimer);
|
||||
if (es) {
|
||||
try { es.close(); } catch (e) {}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
createPoller(refreshPrintQueue, 8000).start();
|
||||
createPoller(refreshSendQueue, 4000).start();
|
||||
createPoller(refreshEvents, 12000).start();
|
||||
createPoller(refreshTasks, 12000).start();
|
||||
createPoller(whenStreamDown(refreshJetzt), 8000).start();
|
||||
})();
|
||||
</script>
|
||||
{% block script %}{% endblock %}
|
||||
|
||||
@@ -6,11 +6,11 @@
|
||||
<meta name="csrf" content="{{ csrf }}">
|
||||
<title>Anmelden · Grotti</title>
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='brand/favicon-32.png') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=40">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=62">
|
||||
</head>
|
||||
<body class="auth-body">
|
||||
<main class="auth-card">
|
||||
<h1 class="auth-brand"><img src="{{ url_for('static', filename='brand/logo.png') }}" alt="" width="40" height="40" decoding="async"> Grotti</h1>
|
||||
<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>
|
||||
<blockquote id="auth-quote-text"></blockquote>
|
||||
<figcaption id="auth-quote-by"></figcaption>
|
||||
|
||||
@@ -14,29 +14,52 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="card bedarf-card" id="bedarf-card" hidden>
|
||||
<div class="lib-head">
|
||||
<h2 id="bedarf">Bedarf aus Warteschlange</h2>
|
||||
<span class="count" id="bedarf-short-count">0</span>
|
||||
</div>
|
||||
<p class="hint" id="bedarf-hint">Fehlmenge = Queue-Gewicht minus Spulenrest (gleiche Sorte/Farbe).</p>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>Sorte</th><th>Farbe</th><th class="num">Queue</th><th class="num">Rest</th><th class="num">Fehlt</th><th class="num">Jobs</th></tr></thead>
|
||||
<tbody id="bedarf-rows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="tabs mat-tabs" role="tablist">
|
||||
<button type="button" class="on" data-view="spools" role="tab"><svg class="ico"><use href="#i-spool"/></svg>Spulen</button>
|
||||
<button type="button" data-view="sorten" role="tab"><svg class="ico"><use href="#i-layers"/></svg>Sorten</button>
|
||||
</div>
|
||||
|
||||
<div id="view-spools">
|
||||
<div class="page-tools mat-tools">
|
||||
<div class="filter-bar" id="spool-filter" role="group" aria-label="Ort">
|
||||
<div class="toolbar page-tools mat-tools">
|
||||
<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" data-filter="loaded">Am Drucker</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="archive">Archiv</button>
|
||||
</div>
|
||||
<div class="mat-extra-filters">
|
||||
<select id="spool-filter-type" title="Sorte"><option value="">Alle Sorten</option></select>
|
||||
<select id="spool-filter-color" title="Farbe"><option value="">Alle Farben</option></select>
|
||||
</div>
|
||||
<div class="search-wrap">
|
||||
<svg class="ico"><use href="#i-search"/></svg>
|
||||
<input id="spool-search" type="search" placeholder="Spule suchen…" autocomplete="off">
|
||||
<button type="button" id="spool-new"><svg class="ico"><use href="#i-spool"/></svg>Neue Spule</button>
|
||||
</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>
|
||||
<p id="spool-list-note" class="tiny"></p>
|
||||
|
||||
<div class="split mat-split is-idle" id="spool-layout">
|
||||
<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>
|
||||
<span class="count" id="spool-count">0</span>
|
||||
<span class="count-pill" id="spool-count">0</span>
|
||||
</div>
|
||||
<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>
|
||||
@@ -121,8 +144,11 @@
|
||||
</details>
|
||||
|
||||
<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-empty" hidden title="Rest auf 0, Regal, archivieren">Leer</button>
|
||||
<button type="button" class="ghost" id="spool-archive" hidden>Archivieren</button>
|
||||
<button type="button" class="ghost" id="spool-restore" hidden>Wiederherstellen</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>
|
||||
</div>
|
||||
@@ -199,7 +225,7 @@
|
||||
<input id="sorten-note" placeholder="optional">
|
||||
</div>
|
||||
<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="warn" id="sorten-delete" hidden><svg class="ico"><use href="#i-trash"/></svg>Löschen</button>
|
||||
</div>
|
||||
@@ -219,6 +245,8 @@ let picked = [];
|
||||
let colorLabel = "";
|
||||
let autoName = "";
|
||||
let spoolFilter = "all";
|
||||
let spoolTypeFilter = "";
|
||||
let spoolColorFilter = "";
|
||||
let spoolSearch = "";
|
||||
let sortenSearch = "";
|
||||
let view = "spools";
|
||||
@@ -538,6 +566,9 @@ function fill(spool) {
|
||||
document.getElementById("spool-delete").hidden = !spool;
|
||||
document.getElementById("spool-label").hidden = !spool;
|
||||
document.getElementById("spool-shelf").hidden = !spool || !(spool.printer_host);
|
||||
document.getElementById("spool-empty").hidden = !spool || spool.archived;
|
||||
document.getElementById("spool-archive").hidden = !spool || spool.archived;
|
||||
document.getElementById("spool-restore").hidden = !spool || !spool.archived;
|
||||
document.getElementById("color-box").open = !spool || !picked.length;
|
||||
showHint();
|
||||
renderPalette();
|
||||
@@ -565,12 +596,30 @@ function fillSorten(material) {
|
||||
renderSorten();
|
||||
}
|
||||
|
||||
function isSpoolArchived(spool) {
|
||||
return !!(spool && (spool.archived || spool.archived_at));
|
||||
}
|
||||
|
||||
function isSpoolEmpty(spool) {
|
||||
return (Number(spool.remaining_g) || 0) <= 0.5;
|
||||
}
|
||||
|
||||
function shownSpools() {
|
||||
const q = spoolSearch.trim().toLowerCase();
|
||||
const type = spoolTypeFilter.trim().toLowerCase();
|
||||
const color = spoolColorFilter.trim().toLowerCase();
|
||||
return spools.filter((spool) => {
|
||||
const archived = isSpoolArchived(spool) || isSpoolEmpty(spool);
|
||||
if (spoolFilter === "archive") {
|
||||
if (!archived) return false;
|
||||
} else if (archived) {
|
||||
return false;
|
||||
}
|
||||
if (spoolFilter === "loaded" && !spool.printer_host) return false;
|
||||
if (spoolFilter === "shelf" && spool.printer_host) return false;
|
||||
if (spoolFilter === "low" && (Number(spool.remaining_g) || 0) >= 100) return false;
|
||||
if (type && (spool.material_type || "").toLowerCase() !== type) return false;
|
||||
if (color && (spool.color || "").toLowerCase() !== color) return false;
|
||||
if (!q) return true;
|
||||
const hay = [
|
||||
spool.name, spool.chargen_nr, spool.code, spool.material_type, spool.color,
|
||||
@@ -580,6 +629,28 @@ function shownSpools() {
|
||||
});
|
||||
}
|
||||
|
||||
function fillSpoolMetaFilters() {
|
||||
const typeSel = document.getElementById("spool-filter-type");
|
||||
const colorSel = document.getElementById("spool-filter-color");
|
||||
if (!typeSel || !colorSel) return;
|
||||
const types = [...new Set(spools.map((s) => (s.material_type || "").trim()).filter(Boolean))]
|
||||
.sort((a, b) => a.localeCompare(b, "de"));
|
||||
const colors = [...new Set(spools.map((s) => (s.color || "").trim()).filter(Boolean))]
|
||||
.sort((a, b) => a.localeCompare(b, "de"));
|
||||
const curType = spoolTypeFilter;
|
||||
const curColor = spoolColorFilter;
|
||||
typeSel.innerHTML = '<option value="">Alle Sorten</option>' + types.map((t) =>
|
||||
`<option value="${escapeHtml(t)}">${escapeHtml(t)}</option>`
|
||||
).join("");
|
||||
colorSel.innerHTML = '<option value="">Alle Farben</option>' + colors.map((c) =>
|
||||
`<option value="${escapeHtml(c)}">${escapeHtml(c)}</option>`
|
||||
).join("");
|
||||
typeSel.value = types.includes(curType) ? curType : "";
|
||||
colorSel.value = colors.includes(curColor) ? curColor : "";
|
||||
spoolTypeFilter = typeSel.value;
|
||||
spoolColorFilter = colorSel.value;
|
||||
}
|
||||
|
||||
function shownSorten() {
|
||||
const q = sortenSearch.trim().toLowerCase();
|
||||
if (!q) return materials;
|
||||
@@ -617,11 +688,15 @@ function renderSpools() {
|
||||
btn.classList.toggle("on", (btn.dataset.filter || "all") === spoolFilter);
|
||||
});
|
||||
fillSelects();
|
||||
fillSpoolMetaFilters();
|
||||
|
||||
const body = document.getElementById("spool-rows");
|
||||
body.innerHTML = "";
|
||||
const shown = shownSpools();
|
||||
document.getElementById("spool-count").textContent = String(shown.length);
|
||||
const activeCount = spools.filter((s) => !isSpoolArchived(s) && !isSpoolEmpty(s)).length;
|
||||
document.getElementById("spool-count").textContent = String(
|
||||
spoolFilter === "archive" ? shown.length : activeCount
|
||||
);
|
||||
const note = document.getElementById("spool-list-note");
|
||||
if (!spools.length) {
|
||||
note.textContent = "";
|
||||
@@ -633,9 +708,11 @@ function renderSpools() {
|
||||
body.innerHTML = '<tr><td class="empty" colspan="6"><div class="empty-state">' + icon("search") + '<p>Keine Spule passt.</p></div></td></tr>';
|
||||
return;
|
||||
}
|
||||
note.textContent = shown.length === spools.length
|
||||
note.textContent = spoolFilter === "archive"
|
||||
? (shown.length === 1 ? "1 archivierte Spule" : shown.length + " archivierte Spulen")
|
||||
: shown.length === activeCount
|
||||
? (shown.length === 1 ? "1 Spule" : shown.length + " Spulen")
|
||||
: shown.length + " von " + spools.length;
|
||||
: shown.length + " von " + activeCount;
|
||||
|
||||
const ordered = [...shown].sort((a, b) => {
|
||||
const ah = a.printer_host || "zzz";
|
||||
@@ -653,7 +730,7 @@ function renderSpools() {
|
||||
body.appendChild(head);
|
||||
}
|
||||
const tr = document.createElement("tr");
|
||||
tr.className = "pick";
|
||||
tr.className = "pick" + (isSpoolArchived(spool) || isSpoolEmpty(spool) ? " is-archived" : "");
|
||||
if (spoolPanelOpen && String(spool.id) === document.getElementById("spool-id").value) tr.classList.add("on");
|
||||
tr.innerHTML = "<td></td><td></td><td></td><td></td><td></td><td class=\"num\"></td>";
|
||||
tr.children[0].textContent = spool.name;
|
||||
@@ -721,17 +798,58 @@ function render() {
|
||||
renderSorten();
|
||||
}
|
||||
|
||||
function renderBedarf(data) {
|
||||
const card = document.getElementById("bedarf-card");
|
||||
const shortfall = (data && data.shortfall) || [];
|
||||
const needed = (data && data.needed) || [];
|
||||
if (!needed.length && !shortfall.length) {
|
||||
card.hidden = true;
|
||||
return;
|
||||
}
|
||||
card.hidden = false;
|
||||
document.getElementById("bedarf-short-count").textContent = shortfall.length
|
||||
? shortfall.length + " Fehl"
|
||||
: "ok";
|
||||
document.getElementById("bedarf-hint").textContent = shortfall.length
|
||||
? shortfall.length + " Sorten reichen nicht für die wartende Queue."
|
||||
: "Queue hat Bedarf — Spulenrest reicht.";
|
||||
const rows = (shortfall.length ? shortfall : needed).map((item) => {
|
||||
const missing = item.shortfall_g != null ? grams(item.shortfall_g) : "—";
|
||||
const rest = item.remaining_g != null
|
||||
? grams(item.remaining_g)
|
||||
: "—";
|
||||
return `<tr class="${item.shortfall_g > 0 ? "warn" : ""}">
|
||||
<td>${escapeHtml(item.material_type || "—")}</td>
|
||||
<td>${escapeHtml(item.color || "—")}</td>
|
||||
<td class="num">${grams(item.weight_g)}</td>
|
||||
<td class="num">${rest}</td>
|
||||
<td class="num">${missing}</td>
|
||||
<td class="num">${item.jobs || 0}</td>
|
||||
</tr>`;
|
||||
}).join("");
|
||||
document.getElementById("bedarf-rows").innerHTML = rows
|
||||
|| '<tr><td class="empty" colspan="6">Kein Queue-Bedarf.</td></tr>';
|
||||
}
|
||||
|
||||
async function load() {
|
||||
[spools, printers, materials, palette] = await Promise.all([
|
||||
const [spoolData, printerData, materialData, paletteData, bedarfData] = await Promise.all([
|
||||
api("/spulen"),
|
||||
api("/drucker?live=0"),
|
||||
api("/materialien"),
|
||||
api("/farben"),
|
||||
api("/bedarf").catch(() => ({ needed: [], shortfall: [] })),
|
||||
]);
|
||||
spools = spoolData;
|
||||
printers = printerData;
|
||||
materials = materialData;
|
||||
palette = paletteData;
|
||||
render();
|
||||
renderPalette();
|
||||
renderBedarf(bedarfData);
|
||||
}
|
||||
|
||||
window.onBedarfChanged = renderBedarf;
|
||||
|
||||
function setScanNote(text, ok) {
|
||||
const note = document.getElementById("scan-note");
|
||||
if (!text) {
|
||||
@@ -817,6 +935,52 @@ document.getElementById("spool-shelf").addEventListener("click", async () => {
|
||||
showNote("spool-note", error);
|
||||
}
|
||||
});
|
||||
document.getElementById("spool-empty").addEventListener("click", () => {
|
||||
const id = document.getElementById("spool-id").value;
|
||||
if (!id) return;
|
||||
armButton(document.getElementById("spool-empty"), "Als leer markieren?", async () => {
|
||||
try {
|
||||
const saved = await api("/spulen/" + id + "/leer", { method: "POST", json: {} });
|
||||
toast("Spule als leer archiviert.");
|
||||
await load();
|
||||
fill(saved);
|
||||
} catch (error) {
|
||||
showNote("spool-note", error);
|
||||
}
|
||||
});
|
||||
});
|
||||
document.getElementById("spool-archive").addEventListener("click", async () => {
|
||||
const id = document.getElementById("spool-id").value;
|
||||
if (!id) return;
|
||||
try {
|
||||
const saved = await api("/spulen/" + id, {
|
||||
method: "PATCH",
|
||||
json: { archived: true, printer_host: "", ace_slot: null },
|
||||
});
|
||||
toast("Spule archiviert.");
|
||||
await load();
|
||||
fill(saved);
|
||||
} catch (error) {
|
||||
showNote("spool-note", error);
|
||||
}
|
||||
});
|
||||
document.getElementById("spool-restore").addEventListener("click", async () => {
|
||||
const id = document.getElementById("spool-id").value;
|
||||
if (!id) return;
|
||||
try {
|
||||
const left = Number(document.getElementById("spool-left").value) || 0;
|
||||
const saved = await api("/spulen/" + id, {
|
||||
method: "PATCH",
|
||||
json: { archived: false, remaining_g: left > 0.5 ? left : 1000 },
|
||||
});
|
||||
toast("Spule wiederhergestellt.");
|
||||
spoolFilter = "all";
|
||||
await load();
|
||||
fill(saved);
|
||||
} catch (error) {
|
||||
showNote("spool-note", error);
|
||||
}
|
||||
});
|
||||
document.getElementById("spool-label").addEventListener("click", () => {
|
||||
const id = document.getElementById("spool-id").value;
|
||||
if (id) window.open("/etikett/" + id, "_blank", "noopener");
|
||||
@@ -831,6 +995,14 @@ document.getElementById("spool-delete").addEventListener("click", () => {
|
||||
closeSpoolPanel();
|
||||
});
|
||||
});
|
||||
document.getElementById("spool-filter-type")?.addEventListener("change", (event) => {
|
||||
spoolTypeFilter = event.target.value || "";
|
||||
renderSpools();
|
||||
});
|
||||
document.getElementById("spool-filter-color")?.addEventListener("change", (event) => {
|
||||
spoolColorFilter = event.target.value || "";
|
||||
renderSpools();
|
||||
});
|
||||
|
||||
document.getElementById("sorten-new").addEventListener("click", () => {
|
||||
fillSorten(null);
|
||||
|
||||
@@ -3,193 +3,309 @@
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1>Projekte</h1>
|
||||
<p class="tiny">Gruppiere Dateien und Läufe.</p>
|
||||
<p class="tiny">Arbeitsmappen mit Ordner unter dem Slice-Pfad — G-code und Konstruktionsdaten.</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="button" class="btn-primary" id="proj-new"><svg class="ico"><use href="#i-plus"/></svg>Neues Projekt</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="split">
|
||||
<section class="card">
|
||||
<div class="card-head lib-head">
|
||||
<svg class="ico head-ico"><use href="#i-folder"/></svg>
|
||||
<h2>Liste</h2>
|
||||
<span class="count-pill" id="proj-count">0</span>
|
||||
</div>
|
||||
<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>
|
||||
<thead><tr><th>Name</th><th>Status</th><th>Ordner</th><th class="num">Dateien</th></tr></thead>
|
||||
<tbody id="proj-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>
|
||||
|
||||
<section class="card" id="proj-panel">
|
||||
<h2 id="proj-title">Neues Projekt</h2>
|
||||
<form id="proj-form" novalidate>
|
||||
<input id="proj-id" type="hidden">
|
||||
<div class="field"><label for="proj-name">Name</label><input id="proj-name" required autocomplete="off"></div>
|
||||
<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>
|
||||
<div class="field">
|
||||
<label for="proj-status">Status</label>
|
||||
<select id="proj-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="field">
|
||||
<label for="proj-folder">Ordner <span class="tip" title="Relativ unter dem Slice-Ordner, z. B. Projekte/2609_002 — kein D:\…"><svg class="ico"><use href="#i-info"/></svg></span></label>
|
||||
<input id="proj-folder" placeholder="Projekte/…" autocomplete="off">
|
||||
<p class="field-help">Leer = automatisch <code>Projekte/<Name></code>. Nur Relativpfad, kein Laufwerk.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field"><label for="proj-notes">Notiz</label><textarea id="proj-notes" rows="3"></textarea></div>
|
||||
<div class="actions">
|
||||
<button type="submit" class="btn-primary" id="proj-save">Speichern</button>
|
||||
<a class="ghost" id="proj-files-link" href="/dateien" hidden>Dateien</a>
|
||||
<button type="button" class="warn" id="proj-delete" hidden><svg class="ico"><use href="#i-trash"/></svg>Löschen</button>
|
||||
</div>
|
||||
</form>
|
||||
<p id="proj-note" class="tiny"></p>
|
||||
|
||||
<div id="proj-detail" hidden>
|
||||
<h3 class="section-gap">Druckdateien</h3>
|
||||
<div id="proj-gcodes" class="proj-file-list"></div>
|
||||
|
||||
<h3 class="section-gap">Weitere Dateien</h3>
|
||||
<p class="tiny">STEP, STL, PDF, SolidWorks usw. im Projektordner — nicht in der Druck-Bibliothek.</p>
|
||||
<div class="actions" style="margin-bottom:0.5rem">
|
||||
<label class="button ghost" for="proj-asset-file"><svg class="ico"><use href="#i-upload"/></svg>Hochladen</label>
|
||||
<input id="proj-asset-file" type="file" multiple hidden>
|
||||
</div>
|
||||
<div id="proj-assets" class="proj-file-list"></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" };
|
||||
const STATUS_LABEL = { idee: "Idee", bereit: "bereit", druckt: "druckt", fertig: "fertig" };
|
||||
let projects = [];
|
||||
let spools = [];
|
||||
let selectedId = null;
|
||||
|
||||
function fillBlank() {
|
||||
selectedId = null;
|
||||
document.getElementById("proj-id").value = "";
|
||||
document.getElementById("proj-name").value = "";
|
||||
document.getElementById("proj-status").value = "idee";
|
||||
document.getElementById("proj-folder").value = "";
|
||||
document.getElementById("proj-notes").value = "";
|
||||
document.getElementById("proj-title").textContent = "Neues Projekt";
|
||||
document.getElementById("proj-delete").hidden = true;
|
||||
document.getElementById("proj-files-link").hidden = true;
|
||||
document.getElementById("proj-detail").hidden = true;
|
||||
document.getElementById("proj-note").textContent = "";
|
||||
renderList();
|
||||
}
|
||||
|
||||
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 = "";
|
||||
selectedId = project.id;
|
||||
document.getElementById("proj-id").value = project.id;
|
||||
document.getElementById("proj-name").value = project.name || "";
|
||||
document.getElementById("proj-status").value = project.status || "idee";
|
||||
document.getElementById("proj-folder").value = project.folder || "";
|
||||
document.getElementById("proj-notes").value = project.notes || "";
|
||||
document.getElementById("proj-title").textContent = project.name || "Projekt";
|
||||
document.getElementById("proj-delete").hidden = false;
|
||||
const link = document.getElementById("proj-files-link");
|
||||
link.hidden = false;
|
||||
link.href = "/dateien";
|
||||
document.getElementById("proj-detail").hidden = false;
|
||||
document.getElementById("proj-note").textContent = "";
|
||||
renderList();
|
||||
renderDetail(project);
|
||||
}
|
||||
|
||||
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 = "";
|
||||
function renderList() {
|
||||
const body = document.getElementById("proj-rows");
|
||||
document.getElementById("proj-count").textContent = String(projects.length);
|
||||
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>';
|
||||
body.innerHTML = '<tr><td class="empty" colspan="4"><div class="empty-state">' + icon("folder") + "<p>Noch kein Projekt.</p></div></td></tr>";
|
||||
return;
|
||||
}
|
||||
body.innerHTML = "";
|
||||
projects.forEach((project) => {
|
||||
const tr = document.createElement("tr");
|
||||
tr.className = "pick";
|
||||
tr.innerHTML = "<td></td><td></td><td></td>";
|
||||
tr.className = "pick" + (String(project.id) === String(selectedId) ? " on" : "");
|
||||
tr.innerHTML = "<td></td><td></td><td></td><td class=\"num\"></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;
|
||||
}
|
||||
});
|
||||
tr.children[1].textContent = STATUS_LABEL[project.status] || project.status || "";
|
||||
tr.children[2].textContent = project.folder || "—";
|
||||
tr.children[2].title = project.folder || "";
|
||||
tr.children[3].textContent = String(project.file_count ?? (project.files || []).length);
|
||||
tr.addEventListener("click", () => openProject(project.id));
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
function renderDetail(project) {
|
||||
const gcodes = document.getElementById("proj-gcodes");
|
||||
const files = project.files || [];
|
||||
if (!files.length) {
|
||||
gcodes.innerHTML = '<p class="tiny">Keine G-code/3MF-Dateien verknüpft. Dateien in den Projektordner legen oder unter Dateien verschieben.</p>';
|
||||
} else {
|
||||
gcodes.innerHTML = files.map((file) => {
|
||||
const mats = (file.filaments || []).map((f) => f.type || f.material_type || f.name || "").filter(Boolean);
|
||||
const mat = [...new Set(mats)].join(" / ");
|
||||
const meta = [mat, file.weight_g ? Math.round(file.weight_g) + " g" : ""].filter(Boolean).join(" · ");
|
||||
return `<a class="proj-file" href="/dateien">${escapeHtml(file.name)}<span class="tiny">${escapeHtml(meta)}</span></a>`;
|
||||
}).join("");
|
||||
}
|
||||
loadAssets(project.id);
|
||||
}
|
||||
|
||||
async function loadAssets(projectId) {
|
||||
const box = document.getElementById("proj-assets");
|
||||
try {
|
||||
const rows = await api("/projekte/" + projectId + "/assets");
|
||||
if (!rows.length) {
|
||||
box.innerHTML = '<p class="tiny">Noch keine weiteren Dateien.</p>';
|
||||
return;
|
||||
}
|
||||
box.innerHTML = rows.map((item) => {
|
||||
const href = withToken("/api/v1/projekte/" + projectId + "/assets/" + encodeURIComponent(item.rel).replace(/%2F/g, "/"));
|
||||
return `<div class="proj-asset">
|
||||
<a href="${escapeHtml(href)}" download>${escapeHtml(item.rel)}</a>
|
||||
<span class="tiny">${escapeHtml(sizeText(item.size_bytes))}</span>
|
||||
<button type="button" class="icon-btn" data-asset-del="${escapeHtml(item.rel)}" title="Löschen">${icon("trash")}</button>
|
||||
</div>`;
|
||||
}).join("");
|
||||
} catch (error) {
|
||||
box.innerHTML = '<p class="tiny">' + escapeHtml(error.message || String(error)) + "</p>";
|
||||
}
|
||||
}
|
||||
|
||||
function sizeText(bytes) {
|
||||
const n = Number(bytes) || 0;
|
||||
if (n < 1024) return n + " B";
|
||||
if (n < 1024 * 1024) return (n / 1024).toFixed(1).replace(".", ",") + " KB";
|
||||
return (n / (1024 * 1024)).toFixed(1).replace(".", ",") + " MB";
|
||||
}
|
||||
|
||||
async function openProject(id) {
|
||||
try {
|
||||
const row = await api("/projekte/" + id);
|
||||
fill(row);
|
||||
} catch (error) {
|
||||
showNote("proj-note", error);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
projects = await api("/projekte");
|
||||
renderList();
|
||||
if (selectedId) {
|
||||
const still = projects.find((p) => String(p.id) === String(selectedId));
|
||||
if (still) await openProject(selectedId);
|
||||
else fillBlank();
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById("project-form").addEventListener("submit", async (event) => {
|
||||
let folderTouched = false;
|
||||
|
||||
document.getElementById("proj-new").addEventListener("click", () => {
|
||||
folderTouched = false;
|
||||
fillBlank();
|
||||
document.getElementById("proj-name").focus();
|
||||
});
|
||||
document.getElementById("proj-folder").addEventListener("input", () => {
|
||||
folderTouched = true;
|
||||
});
|
||||
document.getElementById("proj-name").addEventListener("input", () => {
|
||||
if (folderTouched || document.getElementById("proj-id").value) return;
|
||||
const name = document.getElementById("proj-name").value.trim();
|
||||
const folderEl = document.getElementById("proj-folder");
|
||||
if (!name) {
|
||||
folderEl.value = "";
|
||||
return;
|
||||
}
|
||||
const slug = name
|
||||
.replace(/[^\w\- äöüÄÖÜß.]+/gi, "-")
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/-{2,}/g, "-")
|
||||
.replace(/^[-.]+|[-.]+$/g, "")
|
||||
.slice(0, 60) || "projekt";
|
||||
folderEl.value = "Projekte/" + slug;
|
||||
});
|
||||
|
||||
document.getElementById("proj-form").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const id = document.getElementById("project-id").value;
|
||||
const id = document.getElementById("proj-id").value;
|
||||
const name = document.getElementById("proj-name").value.trim();
|
||||
if (!name) {
|
||||
toast("Name fehlt.", "bad");
|
||||
document.getElementById("proj-name").focus();
|
||||
return;
|
||||
}
|
||||
const folderRaw = document.getElementById("proj-folder").value.trim();
|
||||
if (/^[a-zA-Z]:[\\/]/.test(folderRaw) || folderRaw.startsWith("\\\\") || folderRaw.includes(":")) {
|
||||
toast("Ordner nur relativ unter dem Slice-Pfad — z. B. Projekte/2609_002, kein D:\\…", "bad");
|
||||
document.getElementById("proj-folder").focus();
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
name: document.getElementById("project-name").value,
|
||||
notes: document.getElementById("project-notes").value,
|
||||
status: document.getElementById("project-status").value,
|
||||
name,
|
||||
status: document.getElementById("proj-status").value,
|
||||
notes: document.getElementById("proj-notes").value,
|
||||
folder: folderRaw,
|
||||
};
|
||||
const saveBtn = document.getElementById("proj-save");
|
||||
saveBtn.disabled = true;
|
||||
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.";
|
||||
toast(id ? "Projekt gespeichert." : "Projekt angelegt.", "good");
|
||||
folderTouched = true;
|
||||
await load();
|
||||
fill(saved);
|
||||
await openProject(saved.id);
|
||||
} catch (error) {
|
||||
showNote("project-note", error);
|
||||
showNote("proj-note", error);
|
||||
} finally {
|
||||
saveBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
document.getElementById("project-new").addEventListener("click", () => fill(null));
|
||||
document.getElementById("project-delete").addEventListener("click", (event) => {
|
||||
const id = document.getElementById("project-id").value;
|
||||
|
||||
document.getElementById("proj-delete").addEventListener("click", () => {
|
||||
const id = document.getElementById("proj-id").value;
|
||||
if (!id) return;
|
||||
armButton(event.currentTarget, "Dateien bleiben — löschen?", async () => {
|
||||
armButton(document.getElementById("proj-delete"), "Ordner inkl. Inhalt?", async () => {
|
||||
try {
|
||||
await api("/projekte/" + id, { method: "DELETE" });
|
||||
await api("/projekte/" + id + "?force=1", { method: "DELETE" });
|
||||
toast("Projekt gelöscht.");
|
||||
load();
|
||||
fillBlank();
|
||||
await load();
|
||||
} catch (error) {
|
||||
showNote("project-note", error);
|
||||
showNote("proj-note", error);
|
||||
}
|
||||
});
|
||||
});
|
||||
document.getElementById("quote-go").addEventListener("click", async () => {
|
||||
|
||||
document.getElementById("proj-asset-file").addEventListener("change", async (event) => {
|
||||
const id = document.getElementById("proj-id").value;
|
||||
const files = [...(event.target.files || [])];
|
||||
event.target.value = "";
|
||||
if (!id || !files.length) return;
|
||||
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);
|
||||
for (const file of files) {
|
||||
const body = new FormData();
|
||||
body.append("file", file);
|
||||
await api("/projekte/" + id + "/assets", { method: "POST", body });
|
||||
}
|
||||
toast(files.length === 1 ? "Datei hochgeladen." : files.length + " Dateien hochgeladen.");
|
||||
await loadAssets(id);
|
||||
} catch (error) {
|
||||
showNote("project-note", error);
|
||||
showNote("proj-note", error);
|
||||
}
|
||||
});
|
||||
load().catch((error) => showNote("project-note", error));
|
||||
|
||||
document.getElementById("proj-assets").addEventListener("click", (event) => {
|
||||
const btn = event.target.closest("[data-asset-del]");
|
||||
if (!btn) return;
|
||||
const id = document.getElementById("proj-id").value;
|
||||
const rel = btn.dataset.assetDel;
|
||||
if (!id || !rel) return;
|
||||
armButton(btn, "löschen?", async () => {
|
||||
await api("/projekte/" + id + "/assets/" + encodeURIComponent(rel).replace(/%2F/g, "/"), { method: "DELETE" });
|
||||
toast("Datei gelöscht.");
|
||||
await loadAssets(id);
|
||||
});
|
||||
});
|
||||
|
||||
load().catch((error) => showNote("proj-note", error));
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -9,7 +9,7 @@
|
||||
<button type="button" id="sync"><svg class="ico"><use href="#i-refresh"/></svg>Läufe holen</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="page-tools">
|
||||
<div class="toolbar page-tools">
|
||||
<select id="run-filter-printer" title="Drucker">
|
||||
<option value="">Alle Drucker</option>
|
||||
</select>
|
||||
@@ -23,7 +23,7 @@
|
||||
</div>
|
||||
<p id="run-note"></p>
|
||||
<div class="card table-wrap">
|
||||
<table>
|
||||
<table class="proto-table">
|
||||
<thead><tr><th>Drucker</th><th>Datei</th><th>Zustand</th><th>Dauer</th><th>Ist</th><th>Kosten</th><th>Urteil</th><th>Spule</th><th></th></tr></thead>
|
||||
<tbody id="run-rows"></tbody>
|
||||
</table>
|
||||
@@ -65,6 +65,16 @@ function filteredRuns() {
|
||||
});
|
||||
}
|
||||
|
||||
function shortRunName(filename) {
|
||||
let name = (filename || "").split("/").pop() || "";
|
||||
if (!name) return "";
|
||||
name = name.replace(/^\d{8}-\d{6}-/, "");
|
||||
name = name.replace(/^\d{4}-\d{4}-/, "");
|
||||
name = name.replace(/\.gcode$/i, "");
|
||||
name = name.replace(/_\d+(?:\.\d+)?_(?:\d+h)?(?:\d+m)?(?:\d+s)?$/i, "");
|
||||
return name || (filename || "").split("/").pop() || "";
|
||||
}
|
||||
|
||||
function render() {
|
||||
fillPrinterFilter();
|
||||
const body = document.getElementById("run-rows");
|
||||
@@ -83,19 +93,30 @@ function render() {
|
||||
tr.className = "pick";
|
||||
tr.innerHTML = "<td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td><td></td>";
|
||||
tr.children[0].textContent = run.printer_name;
|
||||
tr.children[0].className = "proto-printer";
|
||||
const fileCell = tr.children[1];
|
||||
fileCell.className = "proto-file";
|
||||
const fullName = (run.filename || "").split("/").pop() || ("#" + run.id);
|
||||
const link = document.createElement("a");
|
||||
link.href = "/protokoll/" + run.id;
|
||||
link.textContent = (run.filename || "").split("/").pop() || ("#" + run.id);
|
||||
link.className = "cell-file";
|
||||
link.title = fullName;
|
||||
link.textContent = shortRunName(run.filename) || fullName;
|
||||
fileCell.appendChild(link);
|
||||
if (run.chargen_nr) {
|
||||
const code = document.createElement("div");
|
||||
code.className = "tiny";
|
||||
code.className = "mono-badge";
|
||||
code.textContent = run.chargen_nr;
|
||||
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].className = "num";
|
||||
const used = Number(run.weight_used_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) : "";
|
||||
@@ -159,7 +180,10 @@ function render() {
|
||||
} else {
|
||||
const spool = spools.find((item) => item.id === run.spool_id);
|
||||
tr.children[7].textContent = spool ? spool.name : "abgezogen";
|
||||
tr.children[7].title = spool ? spool.name : "";
|
||||
tr.children[7].className = "proto-spool";
|
||||
}
|
||||
if (!tr.children[7].className) tr.children[7].className = "proto-spool";
|
||||
if (run.file_id) {
|
||||
const again = document.createElement("button");
|
||||
again.type = "button";
|
||||
|
||||
@@ -6,11 +6,11 @@
|
||||
<meta name="csrf" content="{{ csrf }}">
|
||||
<title>Setup · Grotti</title>
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='brand/favicon-32.png') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=40">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=62">
|
||||
</head>
|
||||
<body class="auth-body">
|
||||
<main class="auth-card">
|
||||
<h1 class="auth-brand"><img src="{{ url_for('static', filename='brand/logo.png') }}" alt="" width="40" height="40" decoding="async"> Grotti einrichten</h1>
|
||||
<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>
|
||||
<form id="setup-form" class="stack">
|
||||
<div class="field">
|
||||
|
||||
@@ -9,30 +9,34 @@
|
||||
<a class="ghost" id="csv-link" href="/api/v1/statistik.csv">CSV</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="page-tools">
|
||||
<div class="segmented period-presets" id="period" role="group" aria-label="Zeitraum">
|
||||
<button type="button" data-period="all" class="on">Alles</button>
|
||||
<button type="button" data-period="day">Tag</button>
|
||||
<button type="button" data-period="week">Woche</button>
|
||||
<button type="button" data-period="month">Monat</button>
|
||||
<button type="button" data-period="yesterday">Gestern</button>
|
||||
<button type="button" data-period="last_week">Letzte Woche</button>
|
||||
<button type="button" data-period="last_month">Letzter Monat</button>
|
||||
<div class="toolbar page-tools stat-tools">
|
||||
<div class="segmented period-presets fchips" id="period" role="group" aria-label="Zeitraum">
|
||||
<button type="button" data-period="all" class="fchip on">Alles</button>
|
||||
<button type="button" data-period="day" class="fchip">Tag</button>
|
||||
<button type="button" data-period="week" class="fchip">Woche</button>
|
||||
<button type="button" data-period="month" class="fchip">Monat</button>
|
||||
<button type="button" data-period="yesterday" class="fchip">Gestern</button>
|
||||
<button type="button" data-period="last_week" class="fchip">Letzte Woche</button>
|
||||
<button type="button" data-period="last_month" class="fchip">Letzter Monat</button>
|
||||
</div>
|
||||
<div class="stat-filters">
|
||||
<input type="date" id="since" title="Von" class="date-field">
|
||||
<input type="date" id="until" title="Bis" class="date-field">
|
||||
<select id="host"><option value="">Alle Drucker</option></select>
|
||||
<input type="text" id="material" placeholder="Sorte" size="10">
|
||||
<input type="text" id="brand" placeholder="Marke" size="10">
|
||||
<button type="button" id="reload">Aktualisieren</button>
|
||||
<input type="text" id="material" placeholder="Sorte" size="8" autocomplete="off">
|
||||
<input type="text" id="brand" placeholder="Marke" size="8" autocomplete="off">
|
||||
<input type="text" id="color" placeholder="Farbe" size="8" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
<p id="stat-note"></p>
|
||||
<div id="stat" class="statistik"></div>
|
||||
{% endblock %}
|
||||
{% block script %}
|
||||
<script>
|
||||
function kpi(label, value, hint) {
|
||||
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>`;
|
||||
function kpi(label, value, hint, ico, extraClass) {
|
||||
const empty = !value || value === "—" || value === "0 %";
|
||||
const cls = ["kpi", extraClass || "", empty ? "kpi-empty" : ""].filter(Boolean).join(" ");
|
||||
return `<div class="${cls}"><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) {
|
||||
@@ -110,6 +114,21 @@ function fillPrinters(rows) {
|
||||
if ([...select.options].some((o) => o.value === current)) select.value = current;
|
||||
}
|
||||
|
||||
function barChart(items, valueKey, labelFn) {
|
||||
const rows = (items || []).filter((item) => Number(item[valueKey]) > 0);
|
||||
if (!rows.length) return '<p class="empty">Keine Werte fürs Diagramm.</p>';
|
||||
const max = Math.max(...rows.map((item) => Number(item[valueKey]) || 0)) || 1;
|
||||
return `<div class="bar-chart" role="img">${rows.map((item) => {
|
||||
const value = Number(item[valueKey]) || 0;
|
||||
const pct = Math.max(4, Math.round(100 * value / max));
|
||||
return `<div class="bar-row">
|
||||
<span class="bar-label">${escapeHtml(labelFn(item))}</span>
|
||||
<span class="bar-track"><span class="bar-fill" style="width:${pct}%"></span></span>
|
||||
<span class="bar-val">${escapeHtml(String(item._barLabel || value))}</span>
|
||||
</div>`;
|
||||
}).join("")}</div>`;
|
||||
}
|
||||
|
||||
function render(data) {
|
||||
fillPrinters(data.by_printer);
|
||||
const o = data.overview || {};
|
||||
@@ -120,6 +139,16 @@ function render(data) {
|
||||
? (data.estimate_ratio * 100).toFixed(0) + " %"
|
||||
: "—";
|
||||
|
||||
if (!jobs) {
|
||||
document.getElementById("stat").innerHTML = `
|
||||
<div class="empty-state">
|
||||
<svg class="ico"><use href="#i-grid"/></svg>
|
||||
<p>Keine Läufe im gewählten Zeitraum.</p>
|
||||
<a class="ghost" href="/protokoll">Protokoll →</a>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const printers = (data.by_printer || []).map((p) => `
|
||||
<tr>
|
||||
<td>${escapeHtml(p.printer_name || p.printer_host)}</td>
|
||||
@@ -140,20 +169,44 @@ function render(data) {
|
||||
<tr>
|
||||
<td>${escapeHtml(m.material_type || "—")}</td>
|
||||
<td>${escapeHtml(m.brand || "")}</td>
|
||||
<td>${escapeHtml(m.color || "")}</td>
|
||||
<td class="num">${grams(m.weight_g)}</td>
|
||||
<td class="num">${euro(m.cost)}</td>
|
||||
<td class="num">${m.jobs}</td>
|
||||
</tr>`).join("") || '<tr><td class="empty" colspan="5">Kein Spulenabzug im Zeitraum.</td></tr>';
|
||||
</tr>`).join("") || '<tr><td class="empty" colspan="6">Kein Spulenabzug im Zeitraum.</td></tr>';
|
||||
|
||||
const chartPrinters = (data.by_printer || []).map((p) => ({
|
||||
...p,
|
||||
_barLabel: hours(p.duration_s),
|
||||
}));
|
||||
const chartMaterials = (data.by_material || []).map((m) => ({
|
||||
...m,
|
||||
_barLabel: grams(m.weight_g),
|
||||
}));
|
||||
|
||||
document.getElementById("stat").innerHTML = `
|
||||
<div class="kpi-row">
|
||||
${kpi("Läufe", String(jobs))}
|
||||
${kpi("Druckzeit", hours(o.duration_s))}
|
||||
${kpi("Verbrauch", grams(o.weight_g))}
|
||||
${kpi("Kosten", euro(o.cost))}
|
||||
${kpi("Erfolg", success + " %", (o.completed || 0) + " fertig · " + (o.failed || 0) + " abgebrochen")}
|
||||
${kpi("Ausschuss", scrap + " %", (o.scrap || 0) + " mit Urteil Ausschuss")}
|
||||
${kpi("Schätztreue", ratio, data.estimate_count ? data.estimate_count + " Läufe mit Soll-Zeit" : "keine Soll-Zeit")}
|
||||
<div class="kpi-row kpi-primary">
|
||||
${kpi("Läufe", String(jobs), "", "layers")}
|
||||
${kpi("Druckzeit", hours(o.duration_s), "", "clock")}
|
||||
${kpi("Verbrauch", grams(o.weight_g), "", "weight")}
|
||||
${kpi("Kosten", euro(o.cost), "", "euro")}
|
||||
${kpi("Erfolg", success + " %", (o.completed || 0) + " fertig · " + (o.failed || 0) + " abgebrochen", "circle-check")}
|
||||
</div>
|
||||
<div class="kpi-row kpi-secondary">
|
||||
${kpi("Ausschuss", scrap + " %", (o.scrap || 0) + " mit Urteil Ausschuss", "ban", "kpi-sm")}
|
||||
${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-sm")}
|
||||
${kpi("€ / Teil", o.cost_per_part != null ? euro(o.cost_per_part) : "—", "Material + Strom ÷ gut", "tag", "kpi-sm")}
|
||||
${kpi("Schätztreue", ratio, data.estimate_count ? data.estimate_count + " Läufe mit Soll-Zeit" : "keine Soll-Zeit", "gauge", "kpi-sm")}
|
||||
</div>
|
||||
<div class="split">
|
||||
<div class="card">
|
||||
<h2>Druckzeit je Drucker</h2>
|
||||
${barChart(chartPrinters, "duration_s", (p) => p.printer_name || p.printer_host || "—")}
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Verbrauch je Material</h2>
|
||||
${barChart(chartMaterials, "weight_g", (m) => [m.material_type, m.brand, m.color].filter(Boolean).join(" · ") || "—")}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Nach Drucker</h2>
|
||||
@@ -172,7 +225,7 @@ function render(data) {
|
||||
<div class="card">
|
||||
<h2>Nach Material</h2>
|
||||
<div class="table-wrap"><table>
|
||||
<thead><tr><th>Sorte</th><th>Marke</th><th>Gramm</th><th>Kosten</th><th>Läufe</th></tr></thead>
|
||||
<thead><tr><th>Sorte</th><th>Marke</th><th>Farbe</th><th>Gramm</th><th>Kosten</th><th>Läufe</th></tr></thead>
|
||||
<tbody>${materials}</tbody>
|
||||
</table></div>
|
||||
</div>`;
|
||||
@@ -185,17 +238,25 @@ async function load() {
|
||||
const host = document.getElementById("host").value;
|
||||
const material = document.getElementById("material").value.trim();
|
||||
const brand = document.getElementById("brand").value.trim();
|
||||
const color = document.getElementById("color").value.trim();
|
||||
if (since) params.set("since", since);
|
||||
if (until) params.set("until", until);
|
||||
if (host) params.set("host", host);
|
||||
if (material) params.set("material", material);
|
||||
if (brand) params.set("brand", brand);
|
||||
if (color) params.set("color", color);
|
||||
const q = params.toString();
|
||||
document.getElementById("csv-link").href = "/api/v1/statistik.csv" + (q ? "?" + q : "");
|
||||
const data = await api("/statistik" + (q ? "?" + q : ""));
|
||||
render(data);
|
||||
}
|
||||
|
||||
let filterTimer = null;
|
||||
function scheduleLoad() {
|
||||
clearTimeout(filterTimer);
|
||||
filterTimer = setTimeout(() => load().catch((error) => showNote("stat-note", error)), 280);
|
||||
}
|
||||
|
||||
function applyPeriod(key) {
|
||||
const range = periodRange(key);
|
||||
document.getElementById("since").value = range.since;
|
||||
@@ -210,17 +271,18 @@ document.getElementById("period").addEventListener("click", (event) => {
|
||||
applyPeriod(button.dataset.period);
|
||||
});
|
||||
|
||||
document.getElementById("reload").addEventListener("click", () => {
|
||||
load().catch((error) => showNote("stat-note", error));
|
||||
});
|
||||
["since", "until"].forEach((id) => {
|
||||
document.getElementById(id).addEventListener("change", () => {
|
||||
clearPeriodActive();
|
||||
load().catch((error) => showNote("stat-note", error));
|
||||
scheduleLoad();
|
||||
});
|
||||
});
|
||||
document.getElementById("host").addEventListener("change", () => {
|
||||
load().catch((error) => showNote("stat-note", error));
|
||||
document.getElementById("host").addEventListener("change", scheduleLoad);
|
||||
["material", "brand", "color"].forEach((id) => {
|
||||
const node = document.getElementById(id);
|
||||
if (!node) return;
|
||||
node.addEventListener("input", scheduleLoad);
|
||||
node.addEventListener("change", scheduleLoad);
|
||||
});
|
||||
load().catch((error) => showNote("stat-note", error));
|
||||
</script>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<section class="desk no-cam" id="desk" hidden data-host="">
|
||||
<header class="desk-bar">
|
||||
<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">
|
||||
<h2 data-role="name"></h2>
|
||||
<span data-role="badge"></span>
|
||||
@@ -27,7 +27,7 @@
|
||||
</header>
|
||||
<div class="desk-stage">
|
||||
<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">
|
||||
<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>
|
||||
@@ -49,26 +49,30 @@
|
||||
<div class="desk-facts" data-role="facts"></div>
|
||||
<div data-role="flow"></div>
|
||||
<div data-role="strips"></div>
|
||||
<details class="desk-fold desk-steer-fold" data-fold="steer">
|
||||
<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 class="desk-foot">
|
||||
<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>
|
||||
</aside>
|
||||
</div>
|
||||
<details class="desk-fold desk-technik" data-fold="technik">
|
||||
<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>
|
||||
<div class="desk-technik-body">
|
||||
<div data-role="tech-facts" class="desk-facts desk-tech-facts"></div>
|
||||
<details class="desk-fold desk-steer-fold" data-fold="steer" open>
|
||||
<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>
|
||||
<div data-role="steer"></div>
|
||||
</details>
|
||||
<div class="desk-tools">
|
||||
<details class="desk-fold desk-tool" data-fold="verlauf" open>
|
||||
<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>
|
||||
<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>
|
||||
<div class="desk-fold-body desk-verlauf" data-role="verlauf">
|
||||
<p class="tiny">Lade Verlauf …</p>
|
||||
</div>
|
||||
</details>
|
||||
<details class="desk-fold desk-tool" data-fold="path">
|
||||
<summary class="desk-fold-sum"><svg class="ico caret"><use href="#i-down"/></svg><span class="grow">3D-Pfad</span><span class="tiny" data-role="path-caption"></span></summary>
|
||||
<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>
|
||||
@@ -82,12 +86,14 @@
|
||||
</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><span class="grow">Bettnetz</span></summary>
|
||||
<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>
|
||||
</section>
|
||||
{% endblock %}
|
||||
{% block script %}
|
||||
@@ -143,7 +149,7 @@ function syncDeskCamCollapsed() {
|
||||
function restoreDeskFolds() {
|
||||
let closed;
|
||||
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) => {
|
||||
box.open = !closed.includes(box.dataset.fold);
|
||||
box.addEventListener("toggle", () => {
|
||||
@@ -176,7 +182,11 @@ function setDeskCameraMode(live, note) {
|
||||
}
|
||||
syncDeskCamCollapsed();
|
||||
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) {
|
||||
@@ -330,6 +340,14 @@ function flowHtml(printer) {
|
||||
return bits.join("");
|
||||
}
|
||||
|
||||
function filamentPauseHint(printer) {
|
||||
if (!printer) return "";
|
||||
if (printer.filament_empty) return printer.pause_reason || printer.message || "Filament leer";
|
||||
const text = String(printer.pause_reason || printer.message || "");
|
||||
if (/filament|spule|material.?leer|gerissen|runout|10107/i.test(text)) return text;
|
||||
return "";
|
||||
}
|
||||
|
||||
function stripsHtml(printer, state) {
|
||||
const bits = [];
|
||||
const busy = state === "printing" || state === "paused" || state === "heating";
|
||||
@@ -338,6 +356,14 @@ function stripsHtml(printer, state) {
|
||||
if (state === "error") {
|
||||
bits.push(`<div class="strip bad">${icon("alert")}<span class="grow">${escapeHtml(printer.message || "Drucker meldet einen Fehler.")}</span><button type="button" class="warn" data-cmd="clear_error">Fehler löschen</button></div>`);
|
||||
}
|
||||
const filamentHint = state === "paused" ? filamentPauseHint(printer) : "";
|
||||
if (filamentHint) {
|
||||
bits.push(`<div class="strip warn filament-swap">
|
||||
<div class="strip-head">${icon("alert")}<span class="grow"><b>Neue Rolle in Material dem Drucker zuweisen</b></span></div>
|
||||
<span class="strip-msg">${escapeHtml(filamentHint)} — sonst zählt der Verbrauch weiter zur alten Spule. Ort = dieser Drucker, speichern, dann Weiter.</span>
|
||||
<div class="strip-actions"><a class="ghost" href="/material">Zu Material</a></div>
|
||||
</div>`);
|
||||
}
|
||||
const failed = !queueBusy && printer.queue_failed;
|
||||
if (failed) {
|
||||
const step = failed.step_label_current || "Schritt " + ((Number(failed.step_index) || 0) + 1);
|
||||
@@ -407,7 +433,11 @@ function aceSlotLabel(slot) {
|
||||
|
||||
function aceSteerHtml(printer) {
|
||||
const dryers = printer.dryers || [];
|
||||
if (!dryers.length) return "";
|
||||
const loaded = wallSpools
|
||||
.filter((s) => s.printer_host === printer.host && s.ace_slot)
|
||||
.slice()
|
||||
.sort((a, b) => Number(a.ace_slot) - Number(b.ace_slot));
|
||||
if (!dryers.length && !loaded.length) return "";
|
||||
const bits = [];
|
||||
dryers.forEach((dryer) => {
|
||||
const id = Number(dryer.id);
|
||||
@@ -433,10 +463,6 @@ function aceSteerHtml(printer) {
|
||||
</div>
|
||||
</div>`);
|
||||
});
|
||||
const loaded = wallSpools
|
||||
.filter((s) => s.printer_host === printer.host && s.ace_slot)
|
||||
.slice()
|
||||
.sort((a, b) => Number(a.ace_slot) - Number(b.ace_slot));
|
||||
if (loaded.length) {
|
||||
bits.push(`<div class="steer-ace-slots"><div class="steer-ace-head"><b>Plätze</b><span class="tiny">Sorte und Farbe aus Grotti</span></div>`);
|
||||
loaded.forEach((spool) => {
|
||||
@@ -462,17 +488,23 @@ function steerPanelHtml(printer) {
|
||||
const fan = typeof printer.fan === "number" ? Math.round(printer.fan * 100) : 0;
|
||||
const aceKey = aceSteerKey(printer);
|
||||
return `<div class="steer" data-steer-host="${escapeHtml(printer.host)}" data-ace-key="${escapeHtml(aceKey)}">
|
||||
<div class="steer-group">
|
||||
<div class="steer-group-label">Heizung</div>
|
||||
<div class="steer-row">
|
||||
<label>${icon("nozzle")} Düse <input type="number" data-steer="nozzle" data-steer-action="nozzle" min="0" max="350" value="${escapeHtml(String(n))}"></label>
|
||||
<label>${icon("bed")} Bett <input type="number" data-steer="bed" data-steer-action="bed" min="0" max="150" value="${escapeHtml(String(b))}"></label>
|
||||
<button type="button" data-steer-go="temps">Temp</button>
|
||||
<button type="button" class="ghost" data-steer-go="heaters_off" title="Heizung und Lüfter aus">Aus</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="steer-group">
|
||||
<div class="steer-group-label">Druck</div>
|
||||
<div class="steer-row">
|
||||
<label>Speed % <input type="number" data-steer="speed" data-steer-action="speed" min="10" max="200" value="${escapeHtml(String(sp))}"></label>
|
||||
<label>Flow % <input type="number" data-steer="flow" data-steer-action="flow" min="50" max="150" value="${escapeHtml(String(flow))}"></label>
|
||||
<label>${icon("fan")} Fan % <input type="number" data-steer="fan" data-steer-action="fan" min="0" max="100" value="${escapeHtml(String(fan))}"></label>
|
||||
</div>
|
||||
</div>
|
||||
${aceSteerHtml(printer)}
|
||||
<p class="tiny">Enter im Feld setzt nur diesen Wert.</p>
|
||||
</div>`;
|
||||
@@ -501,7 +533,9 @@ function heroHtml(printer, state) {
|
||||
}
|
||||
if (state === "complete") {
|
||||
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") {
|
||||
return `<div class="hero idle"><span class="hero-main">abgebrochen</span></div>`;
|
||||
@@ -539,10 +573,6 @@ function patchBar(box, printer, state) {
|
||||
}
|
||||
|
||||
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 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>"
|
||||
@@ -552,28 +582,40 @@ function factsHtml(printer) {
|
||||
${spoolLine}
|
||||
${printer.material ? "<span>Material <b>" + escapeHtml(String(printer.material).split(";")[0]) + "</b></span>" : ""}
|
||||
${printer.weight ? "<span>Gewicht <b>" + grams(printer.weight) + "</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>" : ""}
|
||||
${auxFan ? "<span class=\"fact-ico\">" + icon("fan-aux") + " Aux <b>" + auxFan + "</b></span>" : ""}
|
||||
${speed ? "<span>Speed <b>" + speed + "</b></span>" : ""}
|
||||
${anycubicTaskLine(printer)}
|
||||
<span>Host <b>${escapeHtml(printer.host)}</b></span>
|
||||
${printer.message && printer.ok === false ? `<p class="msg">${escapeHtml(printer.message)}</p>` : ""}
|
||||
`;
|
||||
}
|
||||
|
||||
function factChip(label, value, ico) {
|
||||
if (!value && value !== 0) return "";
|
||||
return `<span class="fact-chip">${ico || ""}<span class="fact-chip-label">${escapeHtml(label)}</span><b>${escapeHtml(String(value))}</b></span>`;
|
||||
}
|
||||
|
||||
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 ? factChip("Teil", part, icon("fan")) : "",
|
||||
chamberFan ? factChip("Bauraum", chamberFan, icon("fan-chamber")) : "",
|
||||
auxFan ? factChip("Aux", auxFan, icon("fan-aux")) : "",
|
||||
speed ? factChip("Speed", speed) : "",
|
||||
anycubicTaskLine(printer),
|
||||
factChip("Host", printer.host),
|
||||
].filter(Boolean).join("");
|
||||
}
|
||||
|
||||
function anycubicTaskLine(printer) {
|
||||
const task = printer.anycubic_task;
|
||||
if (!task || typeof task !== "object") return "";
|
||||
const state = String(task.state || "").trim();
|
||||
const tid = task.task_id != null && String(task.task_id).trim() ? String(task.task_id) : "";
|
||||
const file = task.filename ? String(task.filename) : "";
|
||||
if (!state && !tid && !file) return "";
|
||||
if (!state && !tid) return "";
|
||||
const bits = [];
|
||||
if (state) bits.push(state);
|
||||
if (tid) bits.push("task " + tid);
|
||||
if (file) bits.push(file);
|
||||
return "<span>Anycubic <b>" + escapeHtml(bits.join(" · ")) + "</b></span>";
|
||||
if (tid) bits.push("#" + tid);
|
||||
return factChip("Anycubic", bits.join(" · "));
|
||||
}
|
||||
|
||||
function jobLineVisible(printer, state, filename) {
|
||||
@@ -609,6 +651,7 @@ function buildCard(printer) {
|
||||
article.dataset.needsClear = needsClear(printer) ? "1" : "";
|
||||
article.innerHTML = `
|
||||
<div class="machine-head">
|
||||
<div class="machine-head-main">
|
||||
<div class="machine-title">
|
||||
<h2></h2>
|
||||
${badge}
|
||||
@@ -616,6 +659,8 @@ function buildCard(printer) {
|
||||
</div>
|
||||
<div data-role="hero"></div>
|
||||
</div>
|
||||
<img class="wall-cam-thumb" data-role="wall-cam" alt="" hidden>
|
||||
</div>
|
||||
<div class="machine-body">
|
||||
<div data-role="bar"></div>
|
||||
<div class="stats" data-role="stats"></div>
|
||||
@@ -638,6 +683,59 @@ function buildCard(printer) {
|
||||
return article;
|
||||
}
|
||||
|
||||
/** Wand-Thumb immer Snapshot — nie Stream, unabhängig von der Browser-Einstellung. */
|
||||
function wallCamSnapshotUrl(printer) {
|
||||
if (!printer || !printer.camera || printer.has_camera === false) return "";
|
||||
return withToken(printer.camera) + "&t=" + Math.floor(Date.now() / 3000);
|
||||
}
|
||||
|
||||
function patchWallCam(article, printer) {
|
||||
const img = article.querySelector('[data-role="wall-cam"]');
|
||||
const head = article.querySelector(".machine-head");
|
||||
if (!img) return;
|
||||
const next = wallCamSnapshotUrl(printer);
|
||||
if (!next) {
|
||||
img.hidden = true;
|
||||
img.removeAttribute("src");
|
||||
img.dataset.srcKey = "";
|
||||
img.dataset.pending = "";
|
||||
if (head) head.classList.remove("has-cam");
|
||||
return;
|
||||
}
|
||||
img.alt = "Kamera " + (printer.name || printer.host || "");
|
||||
if (img.dataset.srcKey === next) {
|
||||
img.hidden = false;
|
||||
if (head) head.classList.add("has-cam");
|
||||
return;
|
||||
}
|
||||
img.dataset.pending = next;
|
||||
const probe = new Image();
|
||||
probe.onload = () => {
|
||||
if (img.dataset.pending !== next) return;
|
||||
img.dataset.srcKey = next;
|
||||
img.dataset.pending = "";
|
||||
img.src = next;
|
||||
img.hidden = false;
|
||||
if (head) head.classList.add("has-cam");
|
||||
};
|
||||
probe.onerror = () => {
|
||||
if (img.dataset.pending !== next) return;
|
||||
img.dataset.pending = "";
|
||||
img.hidden = true;
|
||||
img.removeAttribute("src");
|
||||
img.dataset.srcKey = "";
|
||||
if (head) head.classList.remove("has-cam");
|
||||
};
|
||||
probe.src = next;
|
||||
}
|
||||
|
||||
function refreshWallCams() {
|
||||
for (const [host, article] of cardNodes.entries()) {
|
||||
const printer = printerCache.get(host);
|
||||
if (printer) patchWallCam(article, printer);
|
||||
}
|
||||
}
|
||||
|
||||
function steerHasFocus(root) {
|
||||
const active = document.activeElement;
|
||||
return !!(active && root.contains(active) && active.matches("input, select, textarea, button"));
|
||||
@@ -755,6 +853,7 @@ function patchCard(article, printer) {
|
||||
setHtml(role("flow"), flowHtml(printer));
|
||||
setHtml(role("strips"), stripsHtml(printer, state));
|
||||
setHtml(role("controls"), actionsHtml(state, printer));
|
||||
patchWallCam(article, printer);
|
||||
}
|
||||
|
||||
function refreshDeskCamera(printer) {
|
||||
@@ -774,19 +873,19 @@ function refreshDeskCamera(printer) {
|
||||
// Layout (no-cam) nicht bei jedem Frame-Fehler umwerfen — sonst springt die
|
||||
// ganze Bühne inkl. 3D-Viewer alle paar Sekunden.
|
||||
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;
|
||||
img.dataset.srcKey = next;
|
||||
const probe = new Image();
|
||||
probe.onload = () => {
|
||||
const onOk = () => {
|
||||
if (img.dataset.srcKey !== next) return;
|
||||
deskCamFails = 0;
|
||||
img.src = next;
|
||||
img.hidden = false;
|
||||
empty.hidden = true;
|
||||
setDeskCameraMode(true, "live");
|
||||
setDeskCameraMode(true, streaming ? "stream" : "live");
|
||||
};
|
||||
probe.onerror = () => {
|
||||
const onFail = () => {
|
||||
if (img.dataset.srcKey !== next) return;
|
||||
deskCamFails += 1;
|
||||
if (deskCamFails >= 2) {
|
||||
@@ -803,8 +902,17 @@ function refreshDeskCamera(printer) {
|
||||
empty.innerHTML = icon("camera") + "<span>Kamera nicht erreichbar</span>";
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -816,9 +924,18 @@ function startCameraTimer() {
|
||||
if (printer) refreshDeskCamera(printer);
|
||||
};
|
||||
tick();
|
||||
// Stream braucht keinen Poll — MJPEG läuft im <img>.
|
||||
if (typeof cameraMode === "function" && cameraMode() === "stream") return;
|
||||
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() {
|
||||
if (cameraTimer) {
|
||||
clearInterval(cameraTimer);
|
||||
@@ -890,6 +1007,7 @@ function patchDesk(printer) {
|
||||
}
|
||||
setHtml(role("swatches"), swatchesHtml(printer));
|
||||
setHtml(role("facts"), factsHtml(printer));
|
||||
setHtml(role("tech-facts"), techFactsHtml(printer));
|
||||
setHtml(role("flow"), flowHtml(printer));
|
||||
setHtml(role("strips"), stripsHtml(printer, state));
|
||||
setHtml(role("controls"), actionsHtml(state, printer));
|
||||
@@ -1337,6 +1455,7 @@ async function refresh() {
|
||||
}
|
||||
|
||||
window.onQueueChanged = () => refresh().catch(() => {});
|
||||
window.onDruckerLive = () => refresh().catch(() => {});
|
||||
|
||||
document.getElementById("hide-offline").addEventListener("change", (event) => {
|
||||
hideOffline = event.target.checked;
|
||||
@@ -1581,6 +1700,12 @@ async function handleWallClick(event) {
|
||||
}
|
||||
button.disabled = true;
|
||||
try {
|
||||
if (button.dataset.cmd === "fortsetzen") {
|
||||
const card = printerCache.get(host);
|
||||
if (card && filamentPauseHint(card)) {
|
||||
toast("Neue Spule unter Material dem Drucker zuweisen — sonst bleibt der Abzug bei der alten Rolle.");
|
||||
}
|
||||
}
|
||||
await api("/drucker/" + encodeURIComponent(host) + "/" + button.dataset.cmd, { method: "POST", json: {} });
|
||||
showNote("note", null);
|
||||
refresh();
|
||||
@@ -1627,7 +1752,8 @@ deskEl.addEventListener("keydown", async (event) => {
|
||||
});
|
||||
|
||||
refresh().catch((error) => showNote("note", error));
|
||||
setInterval(() => refresh().catch((error) => showNote("note", error)), 5000);
|
||||
createPoller(() => refresh().catch((error) => showNote("note", error)), 12000).start();
|
||||
setInterval(refreshWallCams, 3000);
|
||||
setInterval(() => {
|
||||
api("/dateien").then((rows) => { libraryFiles = rows || []; }).catch(() => {});
|
||||
api("/spulen").then((rows) => { wallSpools = rows || []; }).catch(() => {});
|
||||
|
||||
@@ -3,20 +3,23 @@
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<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 class="page-head-side">
|
||||
<a class="ghost" id="q-bedarf" href="/material#bedarf" hidden></a>
|
||||
<button type="button" class="ghost" id="q-refresh"><svg class="ico"><use href="#i-refresh"/></svg>Neu laden</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-bar" id="q-filter" role="group" aria-label="Status">
|
||||
<div class="toolbar" id="q-filter" role="group" aria-label="Status">
|
||||
<div class="fchips">
|
||||
<button type="button" class="fchip on" data-status="">Alle aktiv</button>
|
||||
<button type="button" class="fchip" data-status="running">Läuft</button>
|
||||
<button type="button" class="fchip" data-status="waiting">Wartet</button>
|
||||
<button type="button" class="fchip" data-status="error">Fehler</button>
|
||||
<button type="button" class="fchip" data-status="history">Historie</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="waiting"><svg class="ico"><use href="#i-clock"/></svg>Wartet</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>
|
||||
<p id="q-note" class="tiny"></p>
|
||||
<div class="card table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
@@ -86,8 +89,13 @@ function render() {
|
||||
if (!shown.length) {
|
||||
const hint = jobs.length
|
||||
? "Nichts in dieser Ansicht."
|
||||
: (liveDirect.length ? "Keine Queue-Aufträge — laufende Direktstarts unter „Läuft“." : "Noch kein Eintrag.");
|
||||
body.innerHTML = '<tr><td class="empty" colspan="6"><div class="empty-state">' + icon("queue") + '<p>' + hint + '</p></div></td></tr>';
|
||||
: (liveDirect.length
|
||||
? "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 = "";
|
||||
return;
|
||||
}
|
||||
@@ -97,11 +105,11 @@ function render() {
|
||||
if (job.direct) {
|
||||
return `<tr data-direct="${escapeHtml(job.host)}">
|
||||
<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>—</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>`;
|
||||
}
|
||||
const waitIdx = waiting.findIndex((j) => j.id === job.id);
|
||||
@@ -122,13 +130,14 @@ function render() {
|
||||
} else if (st === "waiting" || st === "running") {
|
||||
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}">
|
||||
<td>${chipHtml(st === "running" ? "printing" : st === "waiting" ? "waiting" : st === "error" ? "error" : "complete", STATUS_LABEL[st] || st)}</td>
|
||||
<td><span title="${escapeHtml(job.filename || "")}">${escapeHtml(jobLabel(job.filename))}</span>${rep ? " · " + escapeHtml(rep) : ""}</td>
|
||||
<td>${chipHtml(chipState, STATUS_LABEL[st] || st)}</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>${steps || "—"}</td>
|
||||
<td class="tiny">${escapeHtml(job.message || "")}</td>
|
||||
<td class="queue-tools">${tools.join("")}</td>
|
||||
<td class="actions-cell">${tools.join("")}</td>
|
||||
</tr>`;
|
||||
}).join("");
|
||||
document.getElementById("q-note").textContent = shown.length === 1 ? "1 Eintrag" : shown.length + " Einträge";
|
||||
@@ -212,7 +221,18 @@ document.getElementById("q-rows").addEventListener("click", async (event) => {
|
||||
});
|
||||
|
||||
window.onQueueChanged = load;
|
||||
window.onBedarfChanged = (data) => {
|
||||
const link = document.getElementById("q-bedarf");
|
||||
if (!link) return;
|
||||
const n = (data.shortfall || []).length;
|
||||
if (!n) {
|
||||
link.hidden = true;
|
||||
return;
|
||||
}
|
||||
link.hidden = false;
|
||||
link.textContent = n + " Fehlmenge" + (n === 1 ? "" : "n");
|
||||
};
|
||||
load();
|
||||
setInterval(load, 5000);
|
||||
if (typeof refreshBedarf === "function") refreshBedarf();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -5,7 +5,11 @@
|
||||
<h1>Wartung</h1>
|
||||
<p class="tiny">Laufstunden und Filament je Drucker — Verschleißteile im Blick.</p>
|
||||
</div>
|
||||
<div class="page-head-side">
|
||||
<button type="button" class="ghost" id="wartung-refresh"><svg class="ico"><use href="#i-refresh"/></svg>Neu laden</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="wartung-summary" class="kpi-row" hidden></div>
|
||||
<div id="wartung-root"></div>
|
||||
<p id="wartung-note"></p>
|
||||
{% endblock %}
|
||||
@@ -19,22 +23,28 @@ function partRow(part) {
|
||||
<td class="num">${Math.round(part.meters_since || 0).toLocaleString("de-DE")} mm</td>
|
||||
<td class="num">${part.life_hours ? part.life_hours + " h" : "—"}</td>
|
||||
<td class="num">${part.life_meters ? Math.round(part.life_meters) + " mm" : "—"}</td>
|
||||
<td><button type="button" class="ghost sm" data-reset="${part.id}">Zurücksetzen</button></td>
|
||||
<td><button type="button" class="ghost tiny-btn" data-reset="${part.id}">Zurücksetzen</button></td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
function printerCard(block) {
|
||||
const c = block.counters || {};
|
||||
const overdue = (block.parts || []).filter((p) => p.overdue_hours || p.overdue_meters).length;
|
||||
const parts = (block.parts || []).map(partRow).join("")
|
||||
|| '<tr><td colspan="6" class="empty">Noch keine Teile.</td></tr>';
|
||||
return `<section class="card">
|
||||
|| '<tr><td colspan="6" class="empty">Noch keine Teile — unten anlegen.</td></tr>';
|
||||
return `<section class="card wartung-card">
|
||||
<div class="lib-head">
|
||||
<h2>${escapeHtml(block.name || block.host)}</h2>
|
||||
${overdue ? chipHtml("error", overdue + " fällig") : chipHtml("idle", "ok")}
|
||||
</div>
|
||||
<p class="hint">${escapeHtml(block.host)} · ${(c.hours || 0).toLocaleString("de-DE", { maximumFractionDigits: 1 })} h gesamt · ${Math.round(c.meters || 0).toLocaleString("de-DE")} mm · ${c.runs || 0} Läufe</p>
|
||||
<div class="table-wrap"><table>
|
||||
<thead><tr><th>Teil</th><th>Seit Reset</th><th>Seit Reset</th><th>Intervall h</th><th>Intervall mm</th><th></th></tr></thead>
|
||||
<thead><tr><th>Teil</th><th>Seit Reset</th><th>Filament</th><th>Intervall h</th><th>Intervall mm</th><th></th></tr></thead>
|
||||
<tbody>${parts}</tbody>
|
||||
</table></div>
|
||||
<form class="grid-2 wartung-add" data-host="${escapeHtml(block.host)}" style="margin-top:0.75rem">
|
||||
<details class="mat-more section-gap">
|
||||
<summary><svg class="ico caret"><use href="#i-down"/></svg><span class="grow">Teil anlegen</span></summary>
|
||||
<form class="grid-2 wartung-add" data-host="${escapeHtml(block.host)}">
|
||||
<div class="field"><label>Schlüssel</label><input name="part_key" required placeholder="nozzle"></div>
|
||||
<div class="field"><label>Bezeichnung</label><input name="label" placeholder="Düse 0.4"></div>
|
||||
<div class="field"><label>Intervall h</label><input name="life_hours" type="number" step="1" min="0" value="0"></div>
|
||||
@@ -42,15 +52,48 @@ function printerCard(block) {
|
||||
<div class="field" style="grid-column:1/-1"><label>Notiz</label><input name="note"></div>
|
||||
<div class="actions"><button type="submit">Teil anlegen</button></div>
|
||||
</form>
|
||||
</details>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function renderSummary(data) {
|
||||
const box = document.getElementById("wartung-summary");
|
||||
if (!data.length) {
|
||||
box.hidden = true;
|
||||
box.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
let hours = 0;
|
||||
let meters = 0;
|
||||
let overdue = 0;
|
||||
data.forEach((block) => {
|
||||
hours += Number((block.counters || {}).hours) || 0;
|
||||
meters += Number((block.counters || {}).meters) || 0;
|
||||
overdue += (block.parts || []).filter((p) => p.overdue_hours || p.overdue_meters).length;
|
||||
});
|
||||
box.hidden = false;
|
||||
box.innerHTML = `
|
||||
<div class="kpi"><div class="kpi-val">${data.length}</div><div class="kpi-label">Drucker</div></div>
|
||||
<div class="kpi"><div class="kpi-val">${hours.toLocaleString("de-DE", { maximumFractionDigits: 0 })} h</div><div class="kpi-label">Laufzeit</div></div>
|
||||
<div class="kpi"><div class="kpi-val">${Math.round(meters / 1000).toLocaleString("de-DE")} m</div><div class="kpi-label">Filament</div></div>
|
||||
<div class="kpi"><div class="kpi-val">${overdue}</div><div class="kpi-label">Fällig</div></div>`;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const data = await api("/wartung");
|
||||
renderSummary(data || []);
|
||||
document.getElementById("wartung-root").innerHTML = (data || []).map(printerCard).join("")
|
||||
|| '<p class="empty">Keine Drucker angelegt.</p>';
|
||||
|| `<div class="empty-state">
|
||||
<svg class="ico"><use href="#i-nozzle"/></svg>
|
||||
<p>Keine Drucker angelegt.</p>
|
||||
<a class="ghost" href="/einstellungen">Einstellungen →</a>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
document.getElementById("wartung-refresh")?.addEventListener("click", () => {
|
||||
load().catch((error) => showNote("wartung-note", error));
|
||||
});
|
||||
|
||||
document.getElementById("wartung-root").addEventListener("submit", async (event) => {
|
||||
const form = event.target.closest("form.wartung-add");
|
||||
if (!form) return;
|
||||
@@ -70,24 +113,28 @@ document.getElementById("wartung-root").addEventListener("submit", async (event)
|
||||
});
|
||||
form.reset();
|
||||
showNote("wartung-note", null, "Teil gespeichert.");
|
||||
toast("Wartungsteil angelegt.");
|
||||
load();
|
||||
} catch (error) {
|
||||
showNote("wartung-note", error);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("wartung-root").addEventListener("click", async (event) => {
|
||||
document.getElementById("wartung-root").addEventListener("click", (event) => {
|
||||
const btn = event.target.closest("[data-reset]");
|
||||
if (!btn) return;
|
||||
armButton(btn, "Wirklich zurücksetzen?", async () => {
|
||||
try {
|
||||
await api("/wartung/teile/" + btn.dataset.reset + "/reset", { method: "POST", json: {} });
|
||||
showNote("wartung-note", null, "Teil zurückgesetzt.");
|
||||
toast("Zähler zurückgesetzt.");
|
||||
load();
|
||||
} catch (error) {
|
||||
showNote("wartung-note", error);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
load();
|
||||
load().catch((error) => showNote("wartung-note", error));
|
||||
</script>
|
||||
{% endblock %}
|
||||