diff --git a/.cursor/rules/grotti.mdc b/.cursor/rules/grotti.mdc index eb8d7e4..f5a9382 100644 --- a/.cursor/rules/grotti.mdc +++ b/.cursor/rules/grotti.mdc @@ -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`. diff --git a/AGENTS.md b/AGENTS.md index c7ba2e2..92af3c2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`. diff --git a/CHANGELOG.md b/CHANGELOG.md index d8e0746..a7d7165 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,46 @@ Alle Änderungen an Grotti. Format nach [Keep a Changelog](https://keepachangelo ## [Unreleased] +## [3.2.0] – 2026-10-18 + +### Neu + +- **Jetzt-Liste** / Topbar-Chips / Mobile-Tab `/jetzt`: Platte räumen, Queue-Fehler, Bedarf, Wartung, Aufträge, Material-Wartegründe; Farm-ETA; Filament-Pause → „Material nachlegen“. +- **`GET /api/v1/jetzt`**, **`GET /api/v1/prognose`**, **`GET /api/v1/suche`** (Ctrl+K). +- **Filament-Pause:** Pause + ACE/`mmu`/Runout-Hinweis → Event `spule`, Jetzt-Eintrag, Alert. +- **Druckbericht:** Strom-Aufschlüsselung (Grundlast / Düse / Bett / Trockner) mit Tortendiagramm. +- **Material-Routing:** „Nächster freier“ bevorzugt passende Rolle/Farbe/Rest; sonst Wartegrund in der Queue. +- **Termine** (`due_at`) sortieren die Warteschlange vor der Priorität. +- **Auto-Retry** (einmal, Einstellung) bei Queue-/Ablauf-Fehler. +- **Gleich nochmal drucken** im Abräumen-Dialog. +- **Tagesbericht** 7 Uhr (Telegram/ntfy) und Push bei Wartung fällig, Bedarf, Auftrag überfällig. +- **Kosten pro Teil** und Stück-Yield in Bericht, Auftrag und Statistik. +- **Undo-Toast** beim Entfernen aus der Warteschlange; **Ctrl+K**-Suche. +- **Design-Polish:** Lucide-Icons (`icons.svg`), Schrift Inter, Tokens/Komponenten; Topbar und Sidebar-Gruppen. +- **Kamera-Stream:** Snapshot oder MJPEG in Einstellungen und auf der Kameras-Seite. +- **Community-Screenshots** unter `docs/community/`. +- Einstellungen: Abschnitts-Navigation, Autosave, Drucker als Karten mit Dialog. +- Cockpit: Alltag oben, **Technik**-Fold (Steuerung, Verlauf, 3D, Bettnetz) standardmäßig zu. + +### Geändert + +- **Live-Status** in der Topbar (Chips + Popover); Mobile-Tab **Jetzt**. +- **PA12-CF** in Grotti als PA12-CF; am ACE als PA6-CF (ACE kennt den Typ nicht). +- Falsche Sorte warnt in Dateien, hält die Queue nicht an; zu wenig Rest blockiert weiter. +- Alert-Hygiene: `spule`/`start`/`auth` quiet; Sidebar-Label „Meldungen“. +- Status als Chip; Einstellungen-Nav mit Icons; Autosave; Light-Theme; `tabular-nums`. +- Begriffe: Queue-Eintrag = Druck; Empty-States mit Handlung; Primär-/Danger-Buttons. +- Cool-Park blockiert den Queue-Tick nicht mehr (Worker). +- Kunden und Positionen bearbeiten/löschen unter Aufträge. +- `projekte.html` / `ideen.html` entfernt (Redirect bleibt). + +### Behoben + +- **Bedarf/Wartung/Auftrag-Spam:** Ops-Alerts Dedup-Art und Event-`kind` stimmten nicht überein (jede Minute derselbe Hinweis). +- **Platte belegt nach Neustart:** Watcher setzte bereits Quittiertes wieder auf `needs_clear`. +- **„Pausiert“ nach Abbruch/Offline:** Live-Lauf blieb offen bei Offline→standby. +- **Regal-Modus:** Sidebar-Menü unter Desktop-Breite ausgeblendet. + ## [3.1.0] – 2026-10-18 ### Neu diff --git a/config.example.json b/config.example.json index 62c2f00..516ad90 100644 --- a/config.example.json +++ b/config.example.json @@ -8,6 +8,7 @@ "secret_key": "", "fqdn": "", "auto_queue": true, + "auto_retry": true, "oauth": { "enabled": false, "issuer": "", diff --git a/docs/Anleitung-ACE.md b/docs/Anleitung-ACE.md index e676c1d..7850feb 100644 --- a/docs/Anleitung-ACE.md +++ b/docs/Anleitung-ACE.md @@ -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. ![ACE-Zuordnung beim Senden](../web/static/doku/ace-zuordnung.png) @@ -30,7 +30,7 @@ Externe Spule / keine ACE-Slots: keine `.acm` nötig. | Symptom | Check | | --- | --- | | „cannot parse the file“ | ACE-Zuordnung vollständig? Farben richtig? | -| Material-Warnung | Eingelegte Sorte ≠ Datei — Spule wechseln oder Slice prüfen | +| Material-Warnung | Eingelegte Sorte ≠ Datei. Der Start geht trotzdem. PA12-CF steht in Grotti so, an den ACE geht PA6-CF. | | Rest zu gering | Andere Spule oder leichteres Teil | | ACE blinkt nicht / kein Wechsel | Spule und Platz prüfen, ggf. Druck neu starten | diff --git a/docs/Anleitung-Material.md b/docs/Anleitung-Material.md index 3cfd854..f7f3c3f 100644 --- a/docs/Anleitung-Material.md +++ b/docs/Anleitung-Material.md @@ -26,7 +26,9 @@ Unter **Aufträge** Kunden, Positionen und Fortschritt. Dateien lassen sich eine ## Protokoll und Statistik -Abgeschlossene Läufe: Dauer, Ist-Gramm, Kosten, Urteil. Zeile öffnet den **Druckbericht** (Soll/Ist, Spulen, Temperaturverlauf, Charge). **Nochmal** startet denselben Job inkl. gespeicherter ACE-Map und Bettnetz-Option. +Abgeschlossene Läufe: Dauer, Ist-Gramm, Kosten, Urteil. Zeile öffnet den **Druckbericht** (Soll/Ist, Spulen, Temperaturverlauf, Strom-Aufschlüsselung mit Tortendiagramm, Charge). **Nochmal** startet denselben Job inkl. gespeicherter ACE-Map und Bettnetz-Option. + +Nach Filament-Ende: neue Spule vom Regal dem Drucker/ACE-Platz zuweisen, am Gerät laden, an der Wand **Weiter**. Unter **Statistik** KPIs, Balkendiagramme (Druckzeit je Drucker, Verbrauch je Material), Tabellen und CSV-Export. Zeitraum per Knopf oder Von/Bis. @@ -36,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. +`/kameras` — alle Kameras nebeneinander mit Live-Zustand. Oben **Snapshot** oder **Stream** wählbar (gilt für diesen Browser, auch im Cockpit). Stream ist flüssiger, hält aber je sichtbarer Kamera eine Verbindung. ## Aktivität diff --git a/docs/Anleitung-Oberflaeche.md b/docs/Anleitung-Oberflaeche.md index dcb7f2b..4fecdff 100644 --- a/docs/Anleitung-Oberflaeche.md +++ b/docs/Anleitung-Oberflaeche.md @@ -1,39 +1,47 @@ # Oberfläche -Stand **18.10.2026**. Wie die App aufgebaut ist — Menü, Live-Leiste, Desktop und Regal. +Stand **18.10.2026**. Wie die App aufgebaut ist — Menü, Jetzt-Liste, Desktop und Regal. ## Navigation -Links (Desktop): **Drucker**, **Dateien**, **Kameras**, **Warteschlange**, **Aufträge**, **Protokoll**, **Statistik**, **Aktivität**, **Material**, **Wartung**. Darunter **Einstellungen**, **Doku**. Fuß: Version (öffnet die Doku). +Links (Desktop), gruppiert: **Live** (Drucker, Dateien, Kameras, Warteschlange, Aufträge), **Rückschau** (Protokoll, Statistik, Aktivität), **Verwalten** (Material, Wartung). Darüber im Inhaltsbereich die **Topbar**: Suche, Live-Chips, Einstellungen, Doku und Benutzer-Menü (Abmelden, Version). -Unter ca. 760 px: untere Leiste plus **Mehr**-Sheet. Das Seitenmenü lässt sich zur Icon-Rail einklappen. +Unter ca. 760 px: untere Leiste (Drucker, Dateien, Warteschlange, **Jetzt**, Mehr) plus **Mehr**-Sheet (inkl. Aktivität und Abmelden). Das Seitenmenü lässt sich zur Icon-Rail einklappen. -![Seitenleiste mit Live-Boxen](../web/static/doku/sidebar-live.png) +Shortcut **Ctrl+K** (Mac: Cmd+K) oder der Suche-Knopf in der Topbar: Suche über Drucker, Dateien, Spulen, Chargen und Aufträge. -## Live-Leiste +## Jetzt -Unter dem Menü, eigene Scrollfläche. Boxen auf- und zuklappbar (Merker im Browser). Aktualisierung über Live-Stream (SSE), sonst Polling. +Desktop: Chips in der Topbar (**Jetzt**, laufende Drucker, Senden, Meldungen). Klick öffnet ein Popover mit Liste und Aktionen. Bei mehreren Läufen zusätzlich **Alles fertig um …**. -| Box | Inhalt | +Mobile / Touch: Tab **Jetzt** in der unteren Leiste (mit Badge) öffnet `/jetzt` — dieselbe Liste als volle Seite. Aktivität liegt unter **Mehr**. + +| Priorität | Inhalt | | --- | --- | -| **Läuft jetzt** | Eine Zeile pro Drucker (Name · % · Fertig-Uhr); Klick öffnet die Warteschlange, × bricht ab. Auch Direktstarts ohne Queue-Zeile. | -| **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. | -| **Bedarf** | Fehlende Filamentmenge für die Queue (Link zu Material). | +| 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 je Kamera). Auch umschaltbar unter Kameras. 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) diff --git a/docs/Anleitung-Wand.md b/docs/Anleitung-Wand.md index 15eadcc..91e9df9 100644 --- a/docs/Anleitung-Wand.md +++ b/docs/Anleitung-Wand.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. ![Druckerwand](../web/static/doku/wand-uebersicht.png) @@ -18,7 +18,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 +34,11 @@ URL mit Hash: `/#` (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 (oder Kameras-Seite) - 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 Strom nach Grundlast/Düse/Bett) - **3D-Pfad** der laufenden oder gewählten Datei; Objekte ausschließen (mit Bestätigung) - **Bettnetz**-Heatmap, wenn vorhanden diff --git a/docs/community/01-druckerwand.png b/docs/community/01-druckerwand.png new file mode 100644 index 0000000..c8f844f Binary files /dev/null and b/docs/community/01-druckerwand.png differ diff --git a/docs/community/02-warteschlange.png b/docs/community/02-warteschlange.png new file mode 100644 index 0000000..1e738e0 Binary files /dev/null and b/docs/community/02-warteschlange.png differ diff --git a/docs/community/03-material.png b/docs/community/03-material.png new file mode 100644 index 0000000..bf63131 Binary files /dev/null and b/docs/community/03-material.png differ diff --git a/docs/community/04-auftraege.png b/docs/community/04-auftraege.png new file mode 100644 index 0000000..bc809a4 Binary files /dev/null and b/docs/community/04-auftraege.png differ diff --git a/docs/community/05-statistik.png b/docs/community/05-statistik.png new file mode 100644 index 0000000..852ee3d Binary files /dev/null and b/docs/community/05-statistik.png differ diff --git a/docs/community/06-jetzt.png b/docs/community/06-jetzt.png new file mode 100644 index 0000000..3cbf95c Binary files /dev/null and b/docs/community/06-jetzt.png differ diff --git a/docs/community/07-cockpit.png b/docs/community/07-cockpit.png new file mode 100644 index 0000000..be18477 Binary files /dev/null and b/docs/community/07-cockpit.png differ diff --git a/docs/community/08-wartung.png b/docs/community/08-wartung.png new file mode 100644 index 0000000..fb903ed Binary files /dev/null and b/docs/community/08-wartung.png differ diff --git a/docs/community/09-dateien.png b/docs/community/09-dateien.png new file mode 100644 index 0000000..18ab700 Binary files /dev/null and b/docs/community/09-dateien.png differ diff --git a/docs/community/README.md b/docs/community/README.md new file mode 100644 index 0000000..435ec0c --- /dev/null +++ b/docs/community/README.md @@ -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. diff --git a/docs/internal/Abraeumen.md b/docs/internal/Abraeumen.md index 2242d8e..39ba9a4 100644 --- a/docs/internal/Abraeumen.md +++ b/docs/internal/Abraeumen.md @@ -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//abraeumen` mit `confirm`. Fährt keine Bahn. `needs_clear` setzt der Watcher bei Druckende/Abbruch/Fehler (nicht schon beim Start). Solange gesetzt, startet „wenn frei“ den nächsten Job nicht. +- **Wand „Platte geräumt“:** Nur Quittierung. Tabelle `bed_status`, API `POST /api/v1/drucker//abraeumen` mit `confirm`. Fährt keine Bahn. `needs_clear` setzt der Watcher beim Live-Übergang Druckende/Abbruch/Fehler (nicht schon beim Start; nicht blind beim ersten Blick nach Neustart). Solange gesetzt, startet „wenn frei“ den nächsten Job nicht. - **Ablauf (Dateien):** Drucken → Abkühlen → Schieben → Tasten, dann der nächste Druck mit LeviQ. Eine Bestätigung beim Einreihen; Schieben läuft ohne zweite Rückfrage. Nach erfolgreichem Schieben ist die Platte automatisch quittiert. - **Normale Drucke** und „Nochmal“ schieben nicht — nach dem Druck muss man auf der Wand quittieren. diff --git a/docs/internal/Features-UI.md b/docs/internal/Features-UI.md index 8a7df07..3f8fc14 100644 --- a/docs/internal/Features-UI.md +++ b/docs/internal/Features-UI.md @@ -1,33 +1,40 @@ # Grotti: Alltag und Oberfläche -Stand **18.10.2026**, v3.0.0. +Stand **18.10.2026**, v3.2.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-`. +- 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. diff --git a/grotti/catalog.py b/grotti/catalog.py index 4a8c31b..9dd5a33 100644 --- a/grotti/catalog.py +++ b/grotti/catalog.py @@ -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"}, diff --git a/grotti/config.py b/grotti/config.py index 1356bd4..da88ccf 100644 --- a/grotti/config.py +++ b/grotti/config.py @@ -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 diff --git a/grotti/cost.py b/grotti/cost.py index 964384b..1675837 100644 --- a/grotti/cost.py +++ b/grotti/cost.py @@ -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 + ), } diff --git a/grotti/daily_report.py b/grotti/daily_report.py new file mode 100644 index 0000000..c48b1a3 --- /dev/null +++ b/grotti/daily_report.py @@ -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 diff --git a/grotti/jetzt.py b/grotti/jetzt.py new file mode 100644 index 0000000..4d22fdb --- /dev/null +++ b/grotti/jetzt.py @@ -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), + } diff --git a/grotti/jobs.py b/grotti/jobs.py index b7a0f84..0e99420 100644 --- a/grotti/jobs.py +++ b/grotti/jobs.py @@ -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 @@ -127,21 +127,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: - 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, - ) + """Falsche Sorte bricht den Start nicht ab. Die Oberfläche warnt vorher.""" + return def assert_spool_at_host(conn, spool_id, host): @@ -686,7 +673,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) diff --git a/grotti/migrations/010_retry_routing.sql b/grotti/migrations/010_retry_routing.sql new file mode 100644 index 0000000..8507131 --- /dev/null +++ b/grotti/migrations/010_retry_routing.sql @@ -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 ''; diff --git a/grotti/migrations/011_pa12cf.sql b/grotti/migrations/011_pa12cf.sql new file mode 100644 index 0000000..9f4b303 --- /dev/null +++ b/grotti/migrations/011_pa12cf.sql @@ -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'; diff --git a/grotti/moonraker.py b/grotti/moonraker.py index eb34e86..3e58296 100644 --- a/grotti/moonraker.py +++ b/grotti/moonraker.py @@ -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): diff --git a/grotti/notify.py b/grotti/notify.py index f34f082..7e21498 100644 --- a/grotti/notify.py +++ b/grotti/notify.py @@ -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"} diff --git a/grotti/print_queue.py b/grotti/print_queue.py index 52afd1e..612a215 100644 --- a/grotti/print_queue.py +++ b/grotti/print_queue.py @@ -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, }, @@ -828,43 +860,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) - 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_note = "" - if not park["ok"] and park["error"]: - park_note = " — Mitte fehlgeschlagen: %s" % park["error"] - elif park["ok"]: - park_note = " · Düse Mitte" - store.update_print_queue( - conn, - 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), - ), - }, - ) - 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), - ) - except Exception as exc: - self._fail_ablauf(conn, item, printer, "Abkühlen: %s" % exc) + 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) + 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_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, + { + "message": self._ablauf_message( + item, + "kühlt ab (Bett ≤ %.0f °C, Düse ≤ %.0f °C)" + % (cool["bed_max"], cool["nozzle_max"]), + ), + "phase": "cooling", + "phase_started_at": datetime.now(timezone.utc), + }, + ) + self._step_event( + conn, item, printer, + "kühlt ab bis Bett ≤ %.0f °C, Düse ≤ %.0f °C" % (cool["bed_max"], cool["nozzle_max"]), + ) + self._spawn_worker(item_id, work) def _poll_cool(self, conn, item, printer, live, step): cool = eject.normalize_cool(step) @@ -1214,6 +1267,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"], diff --git a/grotti/schema.sql b/grotti/schema.sql index 6319894..c555054 100644 --- a/grotti/schema.sql +++ b/grotti/schema.sql @@ -172,6 +172,8 @@ ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS repeat_total integer NOT NULL D ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS repeat_done integer NOT NULL DEFAULT 0; ALTER TABLE print_queue ADD COLUMN IF NOT EXISTS 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'; diff --git a/grotti/store.py b/grotti/store.py index 71a4619..ec411fa 100644 --- a/grotti/store.py +++ b/grotti/store.py @@ -176,6 +176,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 + ), } @@ -1833,6 +1839,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( """ @@ -1936,6 +1952,9 @@ def statistics(conn, since=None, until=None, host="", material="", brand="", pro ).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 +1965,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": [ { @@ -2186,7 +2209,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): @@ -2232,19 +2259,29 @@ def list_events(conn, limit=40, unread_only=False, host=None, kinds=None, since= return [event_out(row) for row in rows] -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): @@ -2323,6 +2360,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"]), } @@ -2498,6 +2537,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 = [] @@ -2538,32 +2579,156 @@ def update_print_queue(conn, item_id, fields): return print_queue_out(fresh, fresh.get("filename")) if fresh else None +def _spool_material_set(conn, host): + rows = conn.execute( + """ + SELECT lower(coalesce(material_type, '')) AS mt, + lower(coalesce(color, '')) AS color, + coalesce(remaining_g, 0) AS remaining_g + FROM spools + WHERE printer_host = %s + """, + (host or "",), + ).fetchall() + return [ + { + "material_type": (r["mt"] or "").strip(), + "color": (r["color"] or "").strip(), + "remaining_g": float(r["remaining_g"] or 0), + } + for r in rows + ] + + +def _file_needs(conn, file_id): + if not file_id: + return [], 0.0 + row = conn.execute( + "SELECT filaments, weight_g FROM files WHERE id = %s", + (file_id,), + ).fetchone() + if not row: + return [], 0.0 + filaments = row.get("filaments") or [] + if isinstance(filaments, str): + try: + import json + filaments = json.loads(filaments) + except Exception: + filaments = [] + types = [] + for item in filaments if isinstance(filaments, list) else []: + if isinstance(item, dict): + t = (item.get("type") or item.get("material_type") or "").strip().lower() + c = (item.get("color") or "").strip().lower() + if t: + types.append({"material_type": t, "color": c}) + elif isinstance(item, str) and item.strip(): + types.append({"material_type": item.strip().lower(), "color": ""}) + return types, float(row.get("weight_g") or 0) + + +def material_fit_score(conn, host, file_id): + """Höher = besser. -1 nur bei zu wenig Rest. Falsche Sorte startet trotzdem.""" + need, weight = _file_needs(conn, file_id) + have = _spool_material_set(conn, host) + if not need: + return 0, "" + if not have: + return 0, "" + have_types = {h["material_type"] for h in have if h["material_type"]} + need_types = {n["material_type"] for n in need if n["material_type"]} + # Falsche Sorte hält den Job nicht fest. Dateien warnt, der Start geht trotzdem. + score = 0 if (need_types and have_types and need_types.isdisjoint(have_types)) else 10 + # Farbe + Rest + for n in need: + matched = [ + h for h in have + if h["material_type"] == n["material_type"] + and (not n["color"] or h["color"] == n["color"] or not h["color"]) + ] + if matched: + score += 5 + if weight and any(h["remaining_g"] + 1e-6 >= weight for h in matched): + score += 5 + elif weight and all(h["remaining_g"] + 1e-6 < weight for h in matched): + return -1, "braucht %s (%s g fehlen)" % ( + n["material_type"] + ((" " + n["color"]) if n["color"] else ""), + int(max(0, weight - max(h["remaining_g"] for h in matched))), + ) + return score, "" + + def next_waiting_print(conn, host=None, family=None): + """Nächster wartender Druck: Termin (due_at) vor Priorität, Material-Match bevorzugt.""" if host: - row = conn.execute( + rows = conn.execute( """ - SELECT q.*, f.name AS filename + SELECT q.*, f.name AS filename, o.due_at AS order_due_at FROM print_queue q 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): @@ -2633,7 +2798,10 @@ def ensure_bed_after_print(conn, host, ended_at=None, message=""): Deckt verpasste Watcher-Übergänge ab (Neustart, Poll-Lücke): History und 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: @@ -2647,6 +2815,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 @@ -2778,3 +2949,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)] + diff --git a/grotti/version.py b/grotti/version.py index abd3c67..3697541 100644 --- a/grotti/version.py +++ b/grotti/version.py @@ -1,3 +1,3 @@ """Version von Grotti (SemVer). Bei jeder Änderung CHANGELOG.md mitführen.""" -__version__ = "3.1.0" +__version__ = "3.2.0" diff --git a/grotti/watcher.py b/grotti/watcher.py index b05b307..3d88b25 100644 --- a/grotti/watcher.py +++ b/grotti/watcher.py @@ -139,11 +139,22 @@ class StateWatcher: ) self._ensure_start_segment(conn, host, filename, live, run_id) elif prev_state == "printing" and state == "paused": - self._emit(conn, host, name, "start", "%s pausiert." % name, filename, run_id) + reason = (live.get("pause_reason") or "").strip() + if live.get("filament_empty") or reason: + detail = reason or "Filament leer oder gerissen" + self._emit( + conn, host, name, "spule", + "%s pausiert — Material nachlegen (%s)." % (name, detail), + filename, run_id, + ) + else: + self._emit(conn, host, name, "start", "%s pausiert." % name, filename, run_id) elif prev_state == "paused" and state == "printing": 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,26 +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) - self._emit( - conn, - host, - name, - "abbruch", - "%s: Druck abgebrochen (%s)" % (name, filename or "ohne Datei"), - filename, - run_id or prev.get("run_id"), - ) - try: - from grotti import jobs + 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, file_label if file_label != state else "ohne Datei", + ), + file_label if file_label != state else "", + run_id or prev.get("run_id") or (dangling or {}).get("id"), + ) + try: + from grotti import jobs - jobs.sync_history(conn, self.cfg) - except Exception as exc: - print("Watcher Abgleich %s: %s" % (host, exc), flush=True) + jobs.sync_history(conn, self.cfg) + except Exception as exc: + 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 +224,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: diff --git a/scripts/build_icons.py b/scripts/build_icons.py new file mode 100644 index 0000000..5177ad4 --- /dev/null +++ b/scripts/build_icons.py @@ -0,0 +1,207 @@ +"""Lucide-Icons als lokales SVG-Sprite bauen. + + python scripts/build_icons.py + +Schreibt web/static/icons.svg. IDs bleiben i- (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/lucide-static@0.468.0/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 + paths + text = re.sub(r"", "", svg, flags=re.DOTALL).strip() + match = re.search(r"]*>(.*)\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' \n {inner}\n ' + ) + + +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' \n' + f' \n ' + ) + print("placeholder", grotti) + + body = "\n".join(symbols) + OUT.parent.mkdir(parents=True, exist_ok=True) + OUT.write_text( + '\n' + f'\n' + '\n' + f"{body}\n" + "\n", + encoding="utf-8", + ) + print("wrote", OUT, "symbols", len(symbols)) + if failed: + print("warnings:", len(failed)) + + +if __name__ == "__main__": + main() diff --git a/server.py b/server.py index 36aba31..1868d29 100644 --- a/server.py +++ b/server.py @@ -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) diff --git a/tests/test_auth.py b/tests/test_auth.py index b637609..1123d0e 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -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) diff --git a/tests/test_cost_ed.py b/tests/test_cost_ed.py index 21747e2..ddf4f56 100644 --- a/tests/test_cost_ed.py +++ b/tests/test_cost_ed.py @@ -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() diff --git a/tests/test_daily_ops_alerts.py b/tests/test_daily_ops_alerts.py new file mode 100644 index 0000000..371ddbd --- /dev/null +++ b/tests/test_daily_ops_alerts.py @@ -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() diff --git a/tests/test_filament_pause.py b/tests/test_filament_pause.py new file mode 100644 index 0000000..eb5d39a --- /dev/null +++ b/tests/test_filament_pause.py @@ -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() diff --git a/tests/test_print_queue.py b/tests/test_print_queue.py index b7f5d32..07d140c 100644 --- a/tests/test_print_queue.py +++ b/tests/test_print_queue.py @@ -38,12 +38,16 @@ 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"}]) + 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__": diff --git a/web/app.py b/web/app.py index 3d72c96..bbb8095 100644 --- a/web/app.py +++ b/web/app.py @@ -11,7 +11,7 @@ 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 = { @@ -243,10 +244,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": @@ -340,6 +341,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("${escapeHtml(text)}`; + const ico = iconName ? icon(iconName, "chip-ico") : ""; + return `${ico}${escapeHtml(text)}`; } function shownState(printer) { diff --git a/web/static/fonts/InterVariable.woff2 b/web/static/fonts/InterVariable.woff2 new file mode 100644 index 0000000..5a8d3e7 Binary files /dev/null and b/web/static/fonts/InterVariable.woff2 differ diff --git a/web/static/fonts/LICENSE.txt b/web/static/fonts/LICENSE.txt new file mode 100644 index 0000000..9b2ca37 --- /dev/null +++ b/web/static/fonts/LICENSE.txt @@ -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. diff --git a/web/static/grotti.css b/web/static/grotti.css index c4bb2cc..eb21fd7 100644 --- a/web/static/grotti.css +++ b/web/static/grotti.css @@ -1,26 +1,45 @@ [hidden] { display: none !important; } +@font-face { + font-family: Inter; + src: url("/static/fonts/InterVariable.woff2") format("woff2"); + font-weight: 100 900; + font-style: normal; + font-display: swap; +} + :root { color-scheme: dark; - --bg: #0a0a0c; - --ink: #f0f0f2; - --muted: #9a9aa3; - --faint: #8d8d97; - --line: rgba(255, 255, 255, 0.08); - --line-strong: rgba(255, 255, 255, 0.14); - --card: #141418; - --card-2: #0f0f12; + --surface-0: #07070a; + --surface-1: #0a0a0c; + --surface-2: #121216; + --surface-3: #1a1a20; + --bg: var(--surface-1); + --ink: #f4f4f6; + --muted: #a1a1aa; + --faint: #8b8b96; + --line: rgba(255, 255, 255, 0.07); + --line-strong: rgba(255, 255, 255, 0.13); + --card: var(--surface-2); + --card-2: #0e0e12; --accent: #f97316; --accent-ink: #1a0a00; - --accent-soft: rgba(249, 115, 22, 0.18); + --accent-soft: rgba(249, 115, 22, 0.16); --warn: #f87171; --pause: #fbbf24; --track: #25252c; - --shadow: 0 12px 32px rgba(0, 0, 0, 0.45); + --shadow: 0 8px 28px rgba(0, 0, 0, 0.4); + --shadow-sm: 0 1px 0 rgba(255, 255, 255, 0.04) inset; --radius: 14px; - --sidebar: 13.5rem; - --live: #3dce7c; - --live-soft: rgba(61, 206, 124, 0.16); + --r-sm: 8px; + --r-md: 12px; + --r-lg: 16px; + --radius-sm: var(--r-sm); + --sidebar: 15rem; + --topbar-h: 3.25rem; + --live: #34d399; + --live-soft: rgba(52, 211, 153, 0.14); + --font: Inter, "Segoe UI", system-ui, -apple-system, sans-serif; --s1: 0.25rem; --s2: 0.5rem; @@ -29,42 +48,94 @@ --s5: 1.5rem; --s6: 2rem; --t-xs: 0.7rem; - --t-sm: 0.8rem; + --t-sm: 0.8125rem; --t-md: 0.9rem; - --t-lg: 1.1rem; - --t-xl: 1.45rem; - --control-h: 2.15rem; - --radius-sm: 8px; + --t-lg: 1.125rem; + --t-xl: 1.5rem; + --control-h: 2.25rem; - --st-printing: #3dce7c; - --st-printing-soft: rgba(61, 206, 124, 0.14); + --st-printing: #34d399; + --st-printing-soft: rgba(52, 211, 153, 0.14); --st-heating: #fbbf24; --st-heating-soft: rgba(251, 191, 36, 0.14); --st-done: #a5b4fc; --st-done-soft: rgba(165, 180, 252, 0.12); --st-error: #f87171; --st-error-soft: rgba(248, 113, 113, 0.14); - --st-offline: #6b6b75; + --st-offline: #71717a; --st-offline-soft: rgba(255, 255, 255, 0.04); --st-waiting: #f97316; --st-waiting-soft: rgba(249, 115, 22, 0.14); - --st-idle: #9a9aa3; + --st-idle: #a1a1aa; --st-idle-soft: rgba(255, 255, 255, 0.05); --ease: cubic-bezier(0.2, 0.7, 0.2, 1); } + +html[data-theme="light"] { + color-scheme: light; + --surface-0: #ececf0; + --surface-1: #f4f4f6; + --surface-2: #ffffff; + --surface-3: #f8f8fa; + --bg: var(--surface-1); + --ink: #18181b; + --muted: #52525b; + --faint: #71717a; + --line: rgba(0, 0, 0, 0.07); + --line-strong: rgba(0, 0, 0, 0.13); + --card: var(--surface-2); + --card-2: #f0f0f3; + --accent: #ea580c; + --accent-ink: #fff7ed; + --accent-soft: rgba(234, 88, 12, 0.12); + --warn: #dc2626; + --pause: #d97706; + --track: #e4e4ea; + --shadow: 0 8px 24px rgba(20, 20, 30, 0.08); + --shadow-sm: 0 1px 0 rgba(255, 255, 255, 0.8) inset; + --live: #16a34a; + --live-soft: rgba(22, 163, 74, 0.12); + --st-printing: #16a34a; + --st-printing-soft: rgba(22, 163, 74, 0.1); + --st-heating: #d97706; + --st-heating-soft: rgba(217, 119, 6, 0.1); + --st-done: #4f46e5; + --st-done-soft: rgba(79, 70, 229, 0.1); + --st-error: #dc2626; + --st-error-soft: rgba(220, 38, 38, 0.1); + --st-offline: #73737c; + --st-offline-soft: rgba(0, 0, 0, 0.04); + --st-waiting: #ea580c; + --st-waiting-soft: rgba(234, 88, 12, 0.1); + --st-idle: #5c5c66; + --st-idle-soft: rgba(0, 0, 0, 0.04); +} + * { box-sizing: border-box; } html { scrollbar-color: rgba(255, 255, 255, 0.14) transparent; scrollbar-width: thin; } +html[data-theme="light"] { scrollbar-color: rgba(0, 0, 0, 0.18) transparent; } html::-webkit-scrollbar { width: 10px; height: 10px; } html::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.14); border-radius: 99px; border: 2px solid transparent; background-clip: padding-box; } html::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.22); background-clip: padding-box; } +html[data-theme="light"]::-webkit-scrollbar-thumb { background: rgba(0, 0, 0, 0.18); } +html[data-theme="light"]::-webkit-scrollbar-thumb:hover { background: rgba(0, 0, 0, 0.28); } body { margin: 0; - font: 14.5px/1.45 "Segoe UI", system-ui, -apple-system, sans-serif; + font: 14.5px/1.45 var(--font); color: var(--ink); background: - radial-gradient(800px 400px at 0% 0%, rgba(249, 115, 22, 0.07), transparent 55%), + radial-gradient(900px 420px at 0% 0%, rgba(249, 115, 22, 0.07), transparent 55%), var(--bg); -webkit-font-smoothing: antialiased; + font-feature-settings: "cv11", "ss01"; +} +html[data-theme="light"] body { + background: + radial-gradient(900px 420px at 0% 0%, rgba(234, 88, 12, 0.06), transparent 55%), + var(--bg); +} +.tabular, .num, .hero-main, .hero-side, .stat b, .chip, .count, .run-line-text, time { + font-variant-numeric: tabular-nums; } a { color: var(--accent); transition: color 0.15s var(--ease); } a:hover { color: #fdba74; } @@ -126,8 +197,11 @@ summary:focus:not(:focus-visible) { outline: none; } height: 2.5rem; min-width: 2.5rem; } -.nav-main, .nav-secondary { display: flex; flex-direction: column; gap: 0.1rem; } -.nav-main a, .nav-secondary a { +.nav-main { display: flex; flex-direction: column; gap: 0.1rem; } +.nav-main a { + display: flex; + align-items: center; + gap: 0.55rem; color: var(--muted); text-decoration: none; padding: 0.38rem 0.55rem; @@ -135,7 +209,7 @@ summary:focus:not(:focus-visible) { outline: none; } font-size: 0.88rem; transition: color 0.15s var(--ease), background 0.15s var(--ease); } -.nav-main a:hover, .nav-secondary a:hover { color: var(--ink); background: rgba(255, 255, 255, 0.04); } +.nav-main a:hover { color: var(--ink); background: rgba(255, 255, 255, 0.04); } .nav-main a.on { color: var(--ink); background: rgba(255, 255, 255, 0.05); @@ -143,38 +217,9 @@ summary:focus:not(:focus-visible) { outline: none; } border-radius: 0 9px 9px 0; padding-left: calc(0.55rem - 2px); } -.nav-main a { display: flex; align-items: center; gap: 0.55rem; } .nav-main a .ico { opacity: 0.65; width: 1.05em; height: 1.05em; } .nav-main a.on .ico { opacity: 1; color: var(--accent); } -.nav-secondary { - padding-top: 0.55rem; - border-top: 1px solid var(--line); - flex-direction: column; - gap: 0.05rem; -} -.nav-secondary a { - display: flex; - align-items: center; - gap: 0.45rem; - font-size: 0.78rem; - color: var(--faint); - padding: 0.32rem 0.5rem; -} -.nav-secondary a .ico { opacity: 0.7; width: 0.95em; height: 0.95em; } -.nav-secondary a.on { color: var(--accent); background: rgba(249, 115, 22, 0.08); } -.nav-secondary a.on .ico { opacity: 1; } -.side-foot { - display: flex; - justify-content: space-between; - align-items: center; - gap: 0.4rem; - padding: 0.45rem 0.5rem 0; - font-size: 0.7rem; - color: var(--faint); -} -.side-foot a { color: var(--faint); text-decoration: none; font-variant-numeric: tabular-nums; } -.side-foot a:hover { color: var(--accent); } -.side-copy { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; } +.m-only { display: none !important; } html.nav-collapsed, body.nav-collapsed { @@ -197,21 +242,13 @@ body.nav-collapsed .brand-row { flex-direction: column; padding-bottom: 0.35rem; html.nav-collapsed .nav-toggle, body.nav-collapsed .nav-toggle { width: 2.5rem; height: 2.5rem; } html.nav-collapsed .nav-main a, -html.nav-collapsed .nav-secondary a, -body.nav-collapsed .nav-main a, -body.nav-collapsed .nav-secondary a { +body.nav-collapsed .nav-main a { justify-content: center; padding: 0.55rem 0.35rem; min-height: 2.5rem; } html.nav-collapsed .nav-main a span, -html.nav-collapsed .nav-secondary a span, -html.nav-collapsed .live-panel, -html.nav-collapsed .side-copy, -body.nav-collapsed .nav-main a span, -body.nav-collapsed .nav-secondary a span, -body.nav-collapsed .live-panel, -body.nav-collapsed .side-copy { +body.nav-collapsed .nav-main a span { display: none; } html.nav-collapsed .nav-main a.on, @@ -221,15 +258,14 @@ body.nav-collapsed .nav-main a.on { padding-left: 0.35rem; background: var(--accent-soft); } -html.nav-collapsed .side-foot, -body.nav-collapsed .side-foot { justify-content: center; } html.shelf, body.shelf { --control-h: 2.75rem; + --topbar-h: 3.6rem; } -html.shelf .nav-desk, -body.shelf .nav-desk { display: none; } +/* Regal: Bottom-Nav kompakter — Sidebar-Punkte nicht ausblenden + (sonst fehlen Dateien/Aufträge/… auf Desktop-Chrome ohne Mehr-Leiste). */ html.shelf .mobile-nav .nav-desk, body.shelf .mobile-nav .nav-desk { display: none; } html.shelf .mobile-nav .nav-shelf, @@ -271,17 +307,238 @@ body.shelf [data-role="hero"] { font-size: 1.35rem; } body.shelf .page-head { margin-bottom: 0.45rem; } } -.live-panel { +.live-bar { + position: relative; flex: 1 1 auto; - min-height: 0; - overflow-y: auto; - margin: 0.85rem -0.2rem 0.6rem; - padding: 0 0.2rem; + min-width: 0; + display: flex; + align-items: center; + justify-content: flex-end; +} +.live-chips { + display: flex; + align-items: center; + gap: 0.35rem; + flex-wrap: wrap; + justify-content: flex-end; + min-width: 0; +} +.live-eta { + padding: 0.28rem 0.55rem; + border-radius: 8px; + background: rgba(61, 206, 124, 0.1); + border: 1px solid rgba(61, 206, 124, 0.28); + color: var(--ok, #3dce7c); + font-size: var(--t-xs); + font-weight: 650; + white-space: nowrap; +} +.live-chip { + position: relative; + display: inline-flex; + align-items: center; + gap: 0.3rem; + height: 2rem; + padding: 0 0.55rem; + border-radius: 8px; + border: 1px solid var(--line-strong); + background: var(--card-2); + color: var(--ink); + font-size: var(--t-xs); + font-weight: 650; + cursor: pointer; + overflow: hidden; + max-width: 14rem; +} +.live-chip .ico { width: 0.9rem; height: 0.9rem; opacity: 0.75; flex: none; } +.live-chip:hover { border-color: rgba(249, 115, 22, 0.45); color: var(--accent); } +.live-chip.todo { border-color: rgba(99, 102, 241, 0.4); } +.live-chip.bad { border-color: rgba(248, 113, 113, 0.4); } +.live-chip.warn { border-color: rgba(248, 113, 113, 0.55); color: var(--warn); } +.live-chip.run { padding-left: 0.45rem; } +.live-chip-fill { + position: absolute; + inset: 0 auto 0 0; + width: calc(var(--pct, 0) * 1%); + max-width: 100%; + background: rgba(61, 206, 124, 0.22); + pointer-events: none; +} +.live-chip-text { + position: relative; + z-index: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-variant-numeric: tabular-nums; +} +.live-pop { + position: absolute; + top: calc(100% + 0.45rem); + right: 0; + z-index: 40; + width: min(22rem, calc(100vw - 2rem)); + max-height: min(70vh, 28rem); + overflow: auto; + padding: 0.55rem; + border-radius: 12px; + border: 1px solid var(--line-strong); + background: var(--surface-1, var(--card)); + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35); display: flex; flex-direction: column; - gap: 0.65rem; - scrollbar-width: thin; - scrollbar-color: rgba(255, 255, 255, 0.12) transparent; + gap: 0.55rem; +} +.live-panel { + display: flex; + flex-direction: column; + gap: 0.55rem; + min-width: 0; +} +.live-page { + max-width: 36rem; +} +.jetzt-eta { + margin: 0; + padding: 0.4rem 0.65rem; + border-radius: 8px; + background: rgba(61, 206, 124, 0.1); + border: 1px solid rgba(61, 206, 124, 0.28); + color: var(--ok, #3dce7c); + font-size: var(--t-sm); + font-weight: 600; +} +.nav-badge { + position: absolute; + top: 0.2rem; + right: calc(50% - 1.35rem); + min-width: 1.05rem; + height: 1.05rem; + padding: 0 0.28rem; + border-radius: 99px; + background: var(--accent); + color: var(--accent-ink); + font-size: 0.62rem; + font-weight: 700; + line-height: 1.05rem; + text-align: center; +} +.nav-badge.todo { background: var(--st-done); color: #14163a; } +.mobile-nav a { position: relative; } +button.btn-primary, +.go-btn { + background: var(--accent); + color: #1a0f00; + border-color: transparent; + font-weight: 650; +} +button.btn-primary:hover, +.go-btn:hover { filter: brightness(1.08); } +button.btn-danger, +button.warn { + background: transparent; + color: var(--warn); + border-color: rgba(248, 113, 113, 0.45); +} +button.btn-danger:hover, +button.warn:hover { background: var(--st-error-soft); filter: none; } +.settings-layout { + display: grid; + grid-template-columns: 11rem minmax(0, 1fr); + gap: 1.25rem; + align-items: start; +} +.settings-nav { + position: sticky; + top: calc(var(--topbar-h) + 0.75rem); + display: flex; + flex-direction: column; + gap: 0.25rem; +} +.settings-nav a { + display: flex; + align-items: center; + gap: 0.45rem; + padding: 0.45rem 0.65rem; + border-radius: 8px; + color: var(--muted); + text-decoration: none; + font-size: var(--t-sm); +} +.settings-nav a .ico { width: 1em; height: 1em; opacity: 0.7; flex: none; } +.settings-nav a:hover, +.settings-nav a.on { background: rgba(255, 255, 255, 0.05); color: var(--ink); } +.settings-nav a.on .ico, .settings-nav a:hover .ico { opacity: 1; color: var(--accent); } +.settings-save-hint { + font-size: var(--t-xs); + color: var(--ok, #3dce7c); + min-height: 1.2em; +} +.printer-edit-cards { display: grid; gap: 0.75rem; } +.printer-edit-card { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1rem; + border: 1px solid var(--line); + border-radius: 12px; + background: rgba(255, 255, 255, 0.02); +} +.printer-edit-card .grow { flex: 1; min-width: 0; } +.cmdk-dlg { + width: min(32rem, 94vw); + padding: 0; + border: 1px solid var(--line); + border-radius: 14px; + background: var(--panel, #141418); + color: var(--text); +} +.cmdk-dlg::backdrop { background: rgba(0, 0, 0, 0.55); } +.cmdk-wrap { display: flex; flex-direction: column; } +.cmdk-wrap input { + width: 100%; + border: 0; + border-bottom: 1px solid var(--line); + background: transparent; + color: var(--text); + padding: 0.9rem 1rem; + font-size: 1rem; + outline: none; +} +.cmdk-list { + max-height: 18rem; + overflow: auto; + margin: 0; + padding: 0.4rem; + list-style: none; +} +.cmdk-list li { + padding: 0.55rem 0.7rem; + border-radius: 8px; + cursor: pointer; + display: flex; + gap: 0.5rem; + align-items: baseline; +} +.cmdk-list li:hover, +.cmdk-list li.on { background: rgba(255, 140, 0, 0.12); } +.cmdk-list .tiny { color: var(--muted); } +.undo-toast { + display: flex; + align-items: center; + gap: 0.75rem; +} +.undo-toast button { + margin-left: auto; + background: transparent; + border: 0; + color: var(--accent); + font-weight: 650; + cursor: pointer; +} +@media (max-width: 760px) { + .settings-layout { grid-template-columns: 1fr; } + .settings-nav { position: static; flex-direction: row; flex-wrap: wrap; } } .fleet-section .machine-title .badge { display: none; } .queue-box { @@ -291,7 +548,13 @@ body.shelf [data-role="hero"] { font-size: 1.35rem; } background: rgba(255, 255, 255, 0.025); overflow: hidden; } +.queue-box.is-empty { display: none; } .queue-box[open] { border-color: var(--line); } +.run-stack { + flex: none; + margin: 0; +} +.run-stack .queue-list { padding: 0 0.2rem; } .queue-box > summary { list-style: none; cursor: pointer; @@ -310,6 +573,7 @@ body.shelf [data-role="hero"] { font-size: 1.35rem; } align-items: center; gap: 0.4rem; } +h2.queue-title { font-size: var(--t-xs); } .queue-title .grow { flex: 1; } .queue-title .caret { transition: transform 0.2s var(--ease); opacity: 0.6; } .queue-box:not([open]) .queue-title .caret { transform: rotate(-90deg); } @@ -1015,7 +1279,7 @@ input, select, textarea { transition: border-color 0.15s var(--ease), background 0.15s var(--ease); } input:hover, select:hover, textarea:hover { border-color: rgba(255, 255, 255, 0.22); } -input:focus, select:focus, textarea:focus, button:focus-visible, .nav-main a:focus-visible, .nav-secondary a:focus-visible, a:focus-visible { +input:focus, select:focus, textarea:focus, button:focus-visible, .nav-main a:focus-visible, .topbar-tools .icon-btn:focus-visible, a:focus-visible { outline: 2px solid rgba(249, 115, 22, 0.55); outline-offset: 2px; } @@ -1125,13 +1389,6 @@ td select { width: auto; min-width: 8.5rem; padding: 0.3rem 0.45rem; min-height: font-style: normal; } .auth-card .stack { display: grid; gap: 0.75rem; margin-top: 1rem; } -.side-user { - display: flex; - align-items: center; - justify-content: space-between; - gap: 0.5rem; - margin-bottom: 0.35rem; -} .empty { color: var(--muted); padding: 1rem 0.3rem; } .empty-state { text-align: center; @@ -1255,7 +1512,7 @@ button.swatch.on { border-color: var(--accent); background: var(--accent-soft); background: transparent !important; } .mat-color-cap { color: var(--faint); font-size: var(--t-xs); } -.mat-form { position: sticky; top: 0.75rem; } +.mat-form { position: sticky; top: calc(var(--topbar-h) + 0.75rem); } .mat-color, .mat-more { margin: 0.75rem 0 0; @@ -1403,7 +1660,7 @@ body.desk-on main { } .desk-bar { position: sticky; - top: 0; + top: var(--topbar-h); z-index: 5; display: grid; gap: 0.55rem; @@ -2038,6 +2295,17 @@ body.desk-on main { .summary-text b { color: var(--ink); } .page-head-side { display: flex; align-items: center; gap: 0.75rem 1rem; flex-wrap: wrap; } .page-head-side .check { margin: 0; font-size: var(--t-sm); color: var(--muted); } +.cam-mode-field { + margin: 0; + display: inline-flex; + align-items: center; + gap: 0.4rem; +} +.cam-mode-field select { + width: auto; + min-height: 2rem; + margin: 0; +} .job-line[hidden] { display: none; } article.machine.flash { animation: flash 2s var(--ease); } @keyframes flash { 0%, 40% { box-shadow: 0 0 0 2px var(--accent), var(--shadow); } } @@ -2046,7 +2314,7 @@ article.machine.flash { animation: flash 2s var(--ease); } /* ---------- Senden ---------- */ .send-layout { display: grid; grid-template-columns: minmax(0, 1.05fr) minmax(0, 0.95fr); gap: 1rem; align-items: start; } -.send-panel { position: sticky; top: 1rem; } +.send-panel { position: sticky; top: calc(var(--topbar-h) + 1rem); } .lib-head { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; margin-bottom: 0.75rem; } .lib-head h2 { margin: 0; flex: 1; } .lib-tools { @@ -2055,7 +2323,7 @@ article.machine.flash { animation: flash 2s var(--ease); } gap: 0.45rem; margin-bottom: 0.65rem; position: sticky; - top: 0; + top: var(--topbar-h); z-index: 5; background: linear-gradient(180deg, var(--card) 72%, transparent); padding-bottom: 0.35rem; @@ -2271,7 +2539,7 @@ label.button:hover { background: rgba(255, 255, 255, 0.04); } align-items: center; margin-bottom: 1rem; position: sticky; - top: 0; + top: var(--topbar-h); z-index: 5; background: linear-gradient(180deg, var(--bg) 70%, transparent); padding-top: 0.15rem; @@ -2295,7 +2563,7 @@ label.button:hover { background: rgba(255, 255, 255, 0.04); } .fchip.on { background: var(--accent-soft); color: #ffd7b5; border-color: rgba(249, 115, 22, 0.55); } .fchip i { width: 0.45rem; height: 0.45rem; border-radius: 99px; background: currentColor; } .timeline { display: grid; gap: 1.25rem; } -.tl-day h3 { position: sticky; top: 0; z-index: 2; margin: 0 0 0.5rem; padding: 0.4rem 0; font-size: var(--t-xs); text-transform: uppercase; letter-spacing: 0.08em; color: var(--faint); background: linear-gradient(var(--bg) 70%, transparent); } +.tl-day h3 { position: sticky; top: var(--topbar-h); z-index: 2; margin: 0 0 0.5rem; padding: 0.4rem 0; font-size: var(--t-xs); text-transform: uppercase; letter-spacing: 0.08em; color: var(--faint); background: linear-gradient(var(--bg) 70%, transparent); } .tl-list { list-style: none; margin: 0; padding: 0; position: relative; } .tl-list::before { content: ""; position: absolute; left: 4.35rem; top: 0.4rem; bottom: 0.4rem; width: 1px; background: var(--line-strong); } .tl-item { display: grid; grid-template-columns: 3.6rem 1.5rem minmax(0, 1fr); gap: 0 0.25rem; align-items: start; padding: 0.4rem 0; } @@ -2324,7 +2592,7 @@ label.button:hover { background: rgba(255, 255, 255, 0.04); } /* ---------- Doku ---------- */ .doc-layout { display: grid; grid-template-columns: 13rem minmax(0, 1fr); gap: 1.5rem; align-items: start; } -.doc-toc { position: sticky; top: 1rem; display: grid; gap: 0.1rem; font-size: var(--t-sm); } +.doc-toc { position: sticky; top: calc(var(--topbar-h) + 1rem); display: grid; gap: 0.1rem; font-size: var(--t-sm); } .doc-toc p { margin: 0.9rem 0 0.3rem; font-size: var(--t-xs); text-transform: uppercase; letter-spacing: 0.08em; color: var(--faint); font-weight: 650; } .doc-toc p:first-child { margin-top: 0; } .doc-toc a { color: var(--muted); text-decoration: none; padding: 0.28rem 0.55rem; border-radius: 8px; border-left: 2px solid transparent; } @@ -2396,17 +2664,17 @@ label.button:hover { background: rgba(255, 255, 255, 0.04); } .brand-row { padding-bottom: 0; } .brand { padding: 0 0.35rem; } .nav-main { display: none; } + .topbar { display: none; } + .m-only { display: inline-grid !important; } html.shelf .mobile-nav, body.shelf .mobile-nav { grid-template-columns: repeat(5, 1fr); } - .nav-secondary { margin-top: 0; border-top: 0; padding-top: 0; flex: 1; justify-content: flex-end; } - .side-foot { display: none; } - .live-panel { order: 5; width: 100%; flex: none; overflow: visible; margin: 0; padding: 0; } - .live-panel:empty { display: none; } - .queue-box { width: 100%; } - .queue-box.is-empty { display: none; } + .desk-bar, .lib-tools, .filter-bar, .tl-day h3 { top: 0; } + .mat-form { top: 0.75rem; } + .send-panel, .doc-toc { top: 1rem; } .sidebar { border-right: 0; border-bottom: 1px solid var(--line); } + .live-bar { display: none; } .split, .grid-2 { grid-template-columns: 1fr; } .fleet { grid-template-columns: 1fr; } main { padding: 1rem 0.85rem 2rem; } @@ -2450,16 +2718,47 @@ label.button:hover { background: rgba(255, 255, 255, 0.04); } background: var(--accent-soft); } .mobile-nav a.on .ico { opacity: 1; } - .nav-secondary { flex-direction: row; flex-wrap: wrap; } - .nav-secondary a span { display: none; } - .nav-secondary a { padding: 0.4rem; } .nav-main a.on { box-shadow: none; border-radius: 9px; padding-left: 0.55rem; } } /* Druckbericht & Statistik */ .bericht { display: grid; gap: 0.85rem; } -.bericht-head { display: grid; gap: 0.5rem; } +.bericht-head { display: grid; gap: 0.85rem; } +@media (min-width: 720px) { + .bericht-head { + grid-template-columns: minmax(0, 1fr) auto; + align-items: start; + } +} .bericht-meta p { margin: 0.2rem 0; } +.energy-break { + display: flex; + gap: 0.85rem; + align-items: center; + padding: 0.65rem 0.75rem; + border: 1px solid var(--line); + border-radius: var(--radius-sm); + background: var(--card-2); + min-width: min(100%, 18rem); +} +.energy-pie { + width: 4.75rem; + height: 4.75rem; + flex: none; +} +.energy-break-table { flex: 1; min-width: 0; } +.energy-break-table .lib-head { margin-bottom: 0.25rem; } +.energy-break-table h3 { margin: 0; font-size: var(--t-md); } +.energy-break-table table { font-size: var(--t-sm); } +.energy-swatch { + display: inline-block; + width: 0.55rem; + height: 0.55rem; + border-radius: 99px; + margin-right: 0.4rem; + vertical-align: 0.05rem; +} +.energy-total td { font-weight: 650; border-top: 1px solid var(--line); } .temp-chart { width: 100%; max-width: 40rem; @@ -2602,6 +2901,18 @@ ul.plain li:last-child { border-bottom: 0; } background: var(--card-2); border: 1px solid var(--line); } +.mobile-more-user { + display: flex; + align-items: center; + gap: 0.55rem; + margin: 0 0 0.75rem; + padding: 0.65rem 0.75rem; + border-radius: var(--radius-sm); + background: var(--card-2); + border: 1px solid var(--line); +} +.mobile-more-user .grow { display: grid; gap: 0.1rem; min-width: 0; } +.mobile-more-user strong { font-size: 0.9rem; } .mobile-nav .mobile-more-btn { appearance: none; background: transparent; @@ -2631,51 +2942,7 @@ ul.plain li:last-child { border-bottom: 0; } } } -html[data-theme="light"] { - color-scheme: light; - --bg: #f4f4f6; - --ink: #16161a; - --muted: #5c5c66; - --faint: #6e6e78; - --line: rgba(0, 0, 0, 0.08); - --line-strong: rgba(0, 0, 0, 0.14); - --card: #ffffff; - --card-2: #f0f0f3; - --accent: #ea580c; - --accent-ink: #fff7ed; - --accent-soft: rgba(234, 88, 12, 0.12); - --warn: #dc2626; - --pause: #d97706; - --track: #e4e4ea; - --shadow: 0 10px 28px rgba(20, 20, 30, 0.08); - --live: #16a34a; - --live-soft: rgba(22, 163, 74, 0.12); - --st-printing: #16a34a; - --st-printing-soft: rgba(22, 163, 74, 0.1); - --st-heating: #d97706; - --st-heating-soft: rgba(217, 119, 6, 0.1); - --st-done: #4f46e5; - --st-done-soft: rgba(79, 70, 229, 0.1); - --st-error: #dc2626; - --st-error-soft: rgba(220, 38, 38, 0.1); - --st-offline: #73737c; - --st-offline-soft: rgba(0, 0, 0, 0.04); - --st-waiting: #ea580c; - --st-waiting-soft: rgba(234, 88, 12, 0.1); - --st-idle: #5c5c66; - --st-idle-soft: rgba(0, 0, 0, 0.04); -} -html[data-theme="light"] body { - background: - radial-gradient(800px 400px at 0% 0%, rgba(234, 88, 12, 0.06), transparent 55%), - var(--bg); -} html[data-theme="light"] a:hover { color: #c2410c; } -html[data-theme="light"] { - scrollbar-color: rgba(0, 0, 0, 0.18) transparent; -} -html[data-theme="light"]::-webkit-scrollbar-thumb { background: rgba(0, 0, 0, 0.18); } -html[data-theme="light"]::-webkit-scrollbar-thumb:hover { background: rgba(0, 0, 0, 0.28); } .narrow-fields { max-width: 36rem; } .printer-edit-list { display: grid; gap: 0.85rem; } @@ -2724,6 +2991,472 @@ html[data-theme="light"]::-webkit-scrollbar-thumb:hover { background: rgba(0, 0, text-align: right; } +/* —— Design-Polish: Komponenten —— */ +.sidebar { background: var(--surface-0); } +.nav-label { + display: block; + margin: 0.65rem 0.55rem 0.25rem; + font-size: 0.62rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--faint); + opacity: 0.85; +} +html.nav-collapsed .nav-label, +body.nav-collapsed .nav-label { display: none; } +.nav-main a.on { + background: var(--accent-soft); + box-shadow: none; + border-radius: 10px; + padding-left: 0.55rem; + color: var(--ink); +} +.nav-main a.on .ico { color: var(--accent); } +.brand-tools { + display: flex; + align-items: center; + gap: 0.15rem; + flex: none; +} + +.topbar { + position: sticky; + top: 0; + z-index: 30; + height: var(--topbar-h); + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0 1.35rem; + background: var(--surface-0); + border-bottom: 1px solid var(--line); +} +.topbar .cmdk-btn { + flex: 0 1 22rem; + display: inline-flex; + align-items: center; + gap: 0.45rem; + height: 2.25rem; + padding: 0 0.75rem; + border-radius: 9px; + border: 1px solid var(--line-strong); + background: var(--card-2); + color: var(--muted); + font-size: 0.8rem; + font-weight: 600; + cursor: pointer; + min-width: 0; +} +.topbar .cmdk-btn:hover { color: var(--ink); border-color: rgba(249, 115, 22, 0.45); } +.topbar .cmdk-btn .ico { opacity: 0.7; flex: none; } +.topbar .cmdk-btn span { flex: 1; text-align: left; } +.topbar .cmdk-btn kbd { + font: inherit; + font-size: 0.65rem; + padding: 0.1rem 0.35rem; + border-radius: 5px; + background: rgba(255, 255, 255, 0.06); + border: 1px solid var(--line); + color: var(--faint); + margin-left: auto; +} +.topbar-tools { + display: flex; + align-items: center; + gap: 0.25rem; + margin-left: auto; + flex: none; +} +.topbar-tools .icon-btn { + width: 2.25rem; + height: 2.25rem; + min-width: 2.25rem; + border-radius: 9px !important; + border: 1px solid transparent; + color: var(--muted); + text-decoration: none; +} +.topbar-tools .icon-btn:hover { + color: var(--ink); + background: rgba(255, 255, 255, 0.06); + border-color: var(--line); +} +.topbar-tools .icon-btn.on { + color: var(--accent); + background: var(--accent-soft); + border-color: rgba(249, 115, 22, 0.35); +} +.topbar-tools .icon-btn .ico { width: 1.1rem; height: 1.1rem; } + +.side-avatar { + width: 1.85rem; + height: 1.85rem; + border-radius: 99px; + display: grid; + place-items: center; + font-size: 0.72rem; + font-weight: 700; + background: var(--accent-soft); + color: var(--accent); + flex: none; + list-style: none; + cursor: pointer; + border: 1px solid transparent; +} +.side-avatar::-webkit-details-marker { display: none; } +.user-menu { + position: relative; + margin-left: 0.15rem; +} +.user-menu[open] > .side-avatar { + border-color: rgba(249, 115, 22, 0.45); + box-shadow: 0 0 0 2px var(--accent-soft); +} +.user-menu .menu-pop { + position: absolute; + right: 0; + top: calc(100% + 0.4rem); + min-width: 13.5rem; + padding: 0.55rem; + display: grid; + gap: 0.35rem; + background: var(--card); + border: 1px solid var(--line-strong); + border-radius: var(--r-md); + box-shadow: var(--shadow); + z-index: 40; +} +.user-menu .menu-user { + display: grid; + gap: 0.1rem; + padding: 0.25rem 0.4rem 0.45rem; + border-bottom: 1px solid var(--line); + margin-bottom: 0.1rem; +} +.user-menu .menu-user strong { font-size: 0.88rem; } +.user-menu .menu-pop > button, +.user-menu .menu-pop > a { + display: block; + width: 100%; + text-align: left; + padding: 0.45rem 0.55rem; + border-radius: 8px; + text-decoration: none; + color: var(--ink); + font-size: 0.85rem; + border: 0; + background: transparent; + cursor: pointer; +} +.user-menu .menu-pop > button:hover, +.user-menu .menu-pop > a:hover { + background: rgba(255, 255, 255, 0.06); +} +.user-menu .menu-copy { + display: block; + padding: 0.25rem 0.55rem 0.1rem; + color: var(--faint); +} +.user-menu .menu-copy a { color: inherit; text-decoration: none; } +.user-menu .menu-copy a:hover { color: var(--accent); } + +html.shelf .topbar .cmdk-btn, +body.shelf .topbar .cmdk-btn { height: 2.6rem; } +html.shelf .topbar-tools .icon-btn, +body.shelf .topbar-tools .icon-btn { + width: 2.6rem; + height: 2.6rem; + min-width: 2.6rem; +} +html.shelf .side-avatar, +body.shelf .side-avatar { + width: 2.2rem; + height: 2.2rem; + font-size: 0.8rem; +} + +@media (max-width: 760px) { + .topbar { display: none; } + .m-only { display: inline-grid !important; } + .desk-bar, .lib-tools, .filter-bar, .tl-day h3 { top: 0; } + .mat-form { top: 0.75rem; } + .send-panel, .doc-toc { top: 1rem; } +} + +.queue-title .title-ico, +.desk-fold-sum .title-ico { + width: 0.95em; + height: 0.95em; + opacity: 0.7; + margin-right: 0.15rem; + color: var(--accent); +} + +.toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.45rem 0.55rem; + margin: 0 0 0.85rem; + padding: 0.55rem 0.65rem; + background: var(--card); + border: 1px solid var(--line); + border-radius: var(--r-md); +} +.toolbar .search-wrap { + position: relative; + flex: 1 1 12rem; + min-width: 10rem; +} +.toolbar .search-wrap .ico { + position: absolute; + left: 0.65rem; + top: 50%; + transform: translateY(-50%); + opacity: 0.55; + pointer-events: none; +} +.toolbar .search-wrap input { + padding-left: 2.1rem; +} +.toolbar .fchips { display: flex; flex-wrap: wrap; gap: 0.3rem; } +.toolbar .tools-end { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + margin-left: auto; + align-items: center; +} +.toolbar select, .toolbar input[type="search"], .toolbar input:not([type]) { + min-height: 2.05rem; +} + +.card-head { + display: flex; + align-items: center; + gap: 0.55rem; + margin: 0 0 0.75rem; +} +.card-head .ico.head-ico { + width: 1.15rem; + height: 1.15rem; + color: var(--accent); + opacity: 0.9; +} +.card-head h2, .card-head h3 { + margin: 0; + font-size: 1rem; + font-weight: 700; + letter-spacing: -0.02em; + flex: 1; + min-width: 0; +} +.card-head .count-pill { + font-size: 0.72rem; + font-weight: 650; + color: var(--muted); + background: var(--st-idle-soft); + border-radius: 99px; + padding: 0.15rem 0.5rem; +} +.card-head .tools { + display: flex; + gap: 0.3rem; + margin-left: auto; +} + +.chip .chip-ico { width: 0.85em; height: 0.85em; margin-right: 0.1rem; } +.chip.running { --c: var(--st-printing); --cs: var(--st-printing-soft); } +.chip.done { --c: var(--st-done); --cs: var(--st-done-soft); } +.chip.waiting { --c: var(--st-waiting); --cs: var(--st-waiting-soft); } +.chip.dismissed { --c: var(--muted); --cs: var(--st-idle-soft); } +.chip.gut { --c: var(--st-printing); --cs: var(--st-printing-soft); } +.chip.ausschuss { --c: var(--st-error); --cs: var(--st-error-soft); } +.chip.offen { --c: var(--st-heating); --cs: var(--st-heating-soft); } +.chip.warn-due { --c: var(--st-heating); --cs: var(--st-heating-soft); } + +.mono-badge { + display: inline-block; + font-family: ui-monospace, "Cascadia Code", Consolas, monospace; + font-size: 0.72rem; + padding: 0.1rem 0.4rem; + border-radius: 6px; + background: rgba(255, 255, 255, 0.05); + color: var(--muted); + letter-spacing: 0.01em; +} + +.table-wrap table tbody tr:hover td { + background: rgba(255, 255, 255, 0.025); +} +html[data-theme="light"] .table-wrap table tbody tr:hover td { + background: rgba(0, 0, 0, 0.025); +} +td .cell-file { + display: block; + max-width: 28rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +td.actions-cell { + white-space: nowrap; + text-align: right; +} +td.actions-cell .icon-btn { vertical-align: middle; } + +button.is-busy { position: relative; color: transparent !important; pointer-events: none; } +button.is-busy::after { + content: ""; + position: absolute; + width: 1rem; + height: 1rem; + border: 2px solid rgba(255, 255, 255, 0.25); + border-top-color: currentColor; + border-radius: 99px; + animation: spin 0.7s linear infinite; + color: var(--accent-ink); +} +button.ghost.is-busy::after, button.warn.is-busy::after { color: var(--ink); } +@keyframes spin { to { transform: rotate(360deg); } } + +.skeleton { + display: block; + height: 0.85rem; + border-radius: 6px; + background: linear-gradient(90deg, var(--track), rgba(255, 255, 255, 0.06), var(--track)); + background-size: 200% 100%; + animation: shimmer 1.2s ease-in-out infinite; +} +@keyframes shimmer { + 0% { background-position: 100% 0; } + 100% { background-position: -100% 0; } +} +@media (prefers-reduced-motion: reduce) { + .skeleton, button.is-busy::after, .chip.printing::before, .chip.heating::before { animation: none; } +} + +dialog.sheet, dialog.modal, .abraeumen-dialog, .cmd-palette, .mobile-more-sheet { + border: 1px solid var(--line-strong); + border-radius: var(--r-lg); + background: var(--card); + color: var(--ink); + box-shadow: var(--shadow); + padding: 0; + max-width: min(32rem, calc(100vw - 2rem)); +} +dialog::backdrop { background: rgba(0, 0, 0, 0.55); backdrop-filter: blur(4px); } +.dialog-head { + display: flex; + align-items: center; + gap: 0.55rem; + padding: 0.95rem 1.1rem 0.55rem; +} +.dialog-head h2 { margin: 0; font-size: 1.05rem; flex: 1; } +.dialog-body { padding: 0.35rem 1.1rem 1rem; } +.dialog-foot { + display: flex; + justify-content: flex-end; + gap: 0.45rem; + padding: 0.75rem 1.1rem 1rem; + border-top: 1px solid var(--line); +} + +.kpi-row { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(9.5rem, 1fr)); + gap: 0.65rem; + margin-bottom: 1rem; +} +.kpi { + background: var(--card); + border: 1px solid var(--line); + border-radius: var(--r-md); + padding: 0.75rem 0.85rem; + display: grid; + gap: 0.2rem; +} +.kpi .kpi-label { + display: flex; + align-items: center; + gap: 0.35rem; + font-size: 0.68rem; + font-weight: 650; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--faint); +} +.kpi .kpi-label .ico { width: 0.9em; height: 0.9em; color: var(--accent); } +.kpi .kpi-value { + font-size: 1.25rem; + font-weight: 720; + letter-spacing: -0.03em; + font-variant-numeric: tabular-nums; +} +.kpi .kpi-sub { font-size: 0.75rem; color: var(--muted); } + +.rest-bar { + height: 0.35rem; + border-radius: 99px; + background: var(--track); + overflow: hidden; + margin-top: 0.25rem; +} +.rest-bar > i { + display: block; + height: 100%; + background: var(--accent); + border-radius: inherit; +} +.rest-bar.low > i { background: var(--st-error); } +.rest-bar.mid > i { background: var(--st-heating); } + +.send-layout, .split, main > .card { + max-width: 72rem; +} +.app-main main { max-width: 76rem; } + +.settings-save-hint { + min-height: 1.2rem; + font-size: var(--t-sm); + color: var(--st-printing); + font-weight: 600; +} +.section-saved { + font-size: 0.72rem; + color: var(--st-printing); + font-weight: 650; + margin-left: 0.4rem; +} + +.meta-icons { + display: flex; + flex-wrap: wrap; + gap: 0.45rem 0.75rem; + font-size: var(--t-xs); + color: var(--muted); +} +.meta-icons span { + display: inline-flex; + align-items: center; + gap: 0.25rem; +} +.meta-icons .ico { width: 0.85em; height: 0.85em; opacity: 0.7; } + +.step-num { + display: inline-grid; + place-items: center; + width: 1.35rem; + height: 1.35rem; + border-radius: 99px; + background: var(--accent-soft); + color: var(--accent); + font-size: 0.72rem; + font-weight: 700; + margin-right: 0.35rem; +} + @media print { .sidebar, .mobile-nav, .conn-banner, .toast-host, .nav-toggle { display: none !important; } .app-shell { display: block; } diff --git a/web/static/icons.svg b/web/static/icons.svg new file mode 100644 index 0000000..e9cc0d9 --- /dev/null +++ b/web/static/icons.svg @@ -0,0 +1,528 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/web/static/sw.js b/web/static/sw.js index ac80413..c87f7a5 100644 --- a/web/static/sw.js +++ b/web/static/sw.js @@ -1,14 +1,16 @@ /* Nur Static-Assets cachen — nie HTML-Navigation abfangen (sonst falsche Seite in Chrome). */ -const CACHE = "grotti-shell-v2"; +const CACHE = "grotti-shell-v3"; self.addEventListener("install", (event) => { self.skipWaiting(); event.waitUntil( caches.open(CACHE).then((cache) => cache.addAll([ - "/static/grotti.css?v=56", - "/static/api.js?v=29", - "/static/ui.js?v=3", + "/static/grotti.css?v=62", + "/static/api.js?v=32", + "/static/ui.js?v=5", + "/static/icons.svg", + "/static/fonts/InterVariable.woff2", ]).catch(() => undefined) ) ); diff --git a/web/static/ui.js b/web/static/ui.js index c5a0c4a..6cddf90 100644 --- a/web/static/ui.js +++ b/web/static/ui.js @@ -160,6 +160,7 @@ function promptAbraeumen(hostLabel) {
+
@@ -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} 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 = '
'; + 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 = `
+ +
    +
    `; + 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) => + `
  • + ${escapeHtml(item.title)} + ${escapeHtml(item.subtitle || "")} +
  • ` + ).join("") || '
  • Nichts gefunden.
  • '; + } + + 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; diff --git a/web/templates/_live_panel.html b/web/templates/_live_panel.html new file mode 100644 index 0000000..e950f9f --- /dev/null +++ b/web/templates/_live_panel.html @@ -0,0 +1,18 @@ + +
    +

    Jetzt0

    +
      +
      + + + diff --git a/web/templates/aktivitaet.html b/web/templates/aktivitaet.html index eb938d4..3315ddd 100644 --- a/web/templates/aktivitaet.html +++ b/web/templates/aktivitaet.html @@ -11,13 +11,15 @@
      -
      - - - +
      +
      + + + +
      -
      -
      +
      +

      diff --git a/web/templates/auftraege.html b/web/templates/auftraege.html index feca7a6..6dcf89e 100644 --- a/web/templates/auftraege.html +++ b/web/templates/auftraege.html @@ -55,9 +55,9 @@
      - + - +

      @@ -81,11 +81,16 @@
      - Kunde anlegen + Kunde anlegen / bearbeiten
      +
      - +
      + + + +
      @@ -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,7 +250,8 @@ function renderList() { const body = document.getElementById("order-rows"); body.innerHTML = ""; if (!orders.length) { - body.innerHTML = 'Noch kein Auftrag.'; + body.innerHTML = '

      Noch kein Auftrag.

      '; + document.getElementById("order-empty-new")?.addEventListener("click", () => fillOrder(null)); return; } const selected = document.getElementById("order-id").value; @@ -209,7 +260,9 @@ function renderList() { tr.className = "pick" + (selected && String(order.id) === selected ? " on" : ""); tr.innerHTML = ""; 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 { @@ -259,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) { @@ -302,25 +355,60 @@ document.getElementById("item-form").addEventListener("submit", async (event) => document.getElementById("customer-form").addEventListener("submit", async (event) => { event.preventDefault(); + const id = document.getElementById("cust-id").value; + const payload = { + name: document.getElementById("cust-name").value, + email: document.getElementById("cust-email").value, + }; try { - const row = await api("/kunden", { - method: "POST", - json: { - name: document.getElementById("cust-name").value, - email: document.getElementById("cust-email").value, - }, - }); - customers.push(row); + 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)); {% endblock %} diff --git a/web/templates/bericht.html b/web/templates/bericht.html index e85d63f..6a75832 100644 --- a/web/templates/bericht.html +++ b/web/templates/bericht.html @@ -29,6 +29,72 @@ function sourceLabel(source, estimate) { return source || ""; } +const ENERGY_PART_COLORS = { + base: "#94a3b8", + nozzle: "#ea580c", + bed: "#2563eb", + dryer: "#16a34a", +}; + +function kwhLabel(value) { + return (Number(value) || 0).toLocaleString("de-DE", { maximumFractionDigits: 3 }) + " kWh"; +} + +function energyPieSvg(parts) { + const items = (parts || []).filter((p) => Number(p.kwh) > 0); + const total = items.reduce((sum, p) => sum + Number(p.kwh || 0), 0); + if (!items.length || total <= 0) return ""; + const cx = 42; + const cy = 42; + const r = 36; + let angle = -Math.PI / 2; + const slices = items.map((p) => { + const share = Number(p.kwh) / total; + const sweep = share * Math.PI * 2; + const x1 = cx + r * Math.cos(angle); + const y1 = cy + r * Math.sin(angle); + angle += sweep; + const x2 = cx + r * Math.cos(angle); + const y2 = cy + r * Math.sin(angle); + const large = sweep > Math.PI ? 1 : 0; + const color = ENERGY_PART_COLORS[p.key] || "#64748b"; + if (share >= 0.999) { + return ``; + } + return ``; + }).join(""); + return `${slices}`; +} + +function energyBreakdownHtml(energy) { + const parts = energy.parts || []; + if (!parts.length) return ""; + const src = energy.source === "ed" ? "aus ED" : "pauschal"; + const rate = energy.euro_per_kwh != null + ? Number(energy.euro_per_kwh).toLocaleString("de-DE", { style: "currency", currency: "EUR", maximumFractionDigits: 3 }) + "/kWh" + : ""; + const rows = parts.map((p) => { + const color = ENERGY_PART_COLORS[p.key] || "#64748b"; + return ` + ${escapeHtml(p.label)} + ${kwhLabel(p.kwh)} + ${euro(p.cost)} + `; + }).join(""); + return `
      +
      ${energyPieSvg(parts)}
      +
      +

      Strom

      ${escapeHtml(src)}${rate ? " · " + rate : ""}
      +
      + + ${rows} + + +
      AnteilkWh€
      Summe${kwhLabel(energy.kwh)}${euro(energy.cost)}
      +
      +
      `; +} + function render(data) { const run = data.run; const name = (run.filename || "").split("/").pop() || "ohne Datei"; @@ -80,10 +146,12 @@ function render(data) {

      Dauer ${hours(run.duration_s)}${run.estimated_s ? " · Soll " + hours(run.estimated_s) : ""}

      Material ${grams(used)} von ${grams(planned)}${planned ? " (" + (diff > 0 ? "+" : "") + diff + " g)" : ""} ${run.weight_source ? ' · ' + sourceLabel(run.weight_source) + "" : ""}

      -

      Kosten ${euro(cost)} (Filament ${euro(run.filament_cost)} · Strom ${euro(energyEuro)}${energyHint ? " · " + energyHint : ""})

      +

      Kosten ${euro(cost)} (Filament ${euro(run.filament_cost)} · Strom ${euro(energyEuro)}${energyHint ? " · " + energyHint : ""})${run.cost_per_part != null ? " · " + euro(run.cost_per_part) + "/Teil" : ""}

      + ${run.pieces_ok || run.pieces_bad ? `

      Stück ${run.pieces_ok || 0} gut${run.pieces_bad ? " · " + run.pieces_bad + " schlecht" : ""}

      ` : ""} ${run.message ? "

      " + escapeHtml(run.message) + "

      " : ""} ${data.project ? "

      Projekt: " + escapeHtml(data.project.name) + "

      " : ""}
      + ${energyBreakdownHtml(energy)}

      Spulen

      diff --git a/web/templates/dateien.html b/web/templates/dateien.html index 19cd8d3..3682bfa 100644 --- a/web/templates/dateien.html +++ b/web/templates/dateien.html @@ -18,12 +18,16 @@
      -
      +
      +

      Bibliothek

      - 0 + 0
      -
      - +
      +
      + + +
      +
      + + +
      -

      Regal: größere Bedienelemente — Shortcut ?shelf=1. Darstellung und Zeitzone nur dieser Browser.

      +

      Regal: größere Bedienelemente — Shortcut ?shelf=1. Darstellung, Zeitzone und Kamera nur dieser Browser. Stream hält eine Verbindung je sichtbarer Kamera.

      -
      +

      Automatik

      -

      Aus = keine neuen Starts. Laufende Abläufe und gesperrte Drucker bleiben unberührt.

      -
      -

      + +

      Aus = keine neuen Starts. Laufende Abläufe und gesperrte Drucker bleiben unberührt. Änderungen speichern sich selbst.

      +
      +

      -
      +

      Slice-Ordner

      Ordner, den Grotti für die Bibliothek beobachtet (Orca-Export, „Ordner neu lesen“). Leer = nur manueller Upload. Standard: Ordner slice im Grotti-Repo.

      @@ -57,13 +82,13 @@
      - +

      -
      +

      Öffentliche Adresse

      FQDN bzw. Basis-URL für QR-Codes auf Spulen-Etiketten. Leer = Host der aktuellen Anfrage (z. B. localhost).

      @@ -71,14 +96,14 @@

      Ohne Schema wird https:// ergänzt. Mit Port: http://10.10.1.5:8770.

      -
      +

      -
      +

      Drucker

      -

      Gesperrt = kein neuer „wenn frei“-Start. Zeitfenster begrenzt Starts (z. B. Nachtstrom).

      -
      +

      Gesperrt = kein neuer „wenn frei“-Start. Zeitfenster begrenzt Starts (z. B. Nachtstrom). Tippen öffnet den Dialog.

      +
      @@ -101,7 +126,7 @@

      -
      +

      Telegram

      @@ -124,13 +149,13 @@

      Ereignisse stehen immer unter Aktivität. Die Häkchen steuern nur Telegram.

      - +

      -
      +

      ntfy

      @@ -149,13 +174,13 @@

      Gleiche Meldungen wie Telegram — unabhängig abschaltbar. Push in der ntfy-App auf dem Handy.

      - +

      -
      +

      Backup

      PostgreSQL-Dump und Datenordner (ohne toolpath/ und alte Backups).

      @@ -165,7 +190,7 @@

      -
      +

      Energie

      Kosten im Protokoll. Mit Düse-/Bett-Watt und ED-Proben schätzt Grotti den Verbrauch; sonst Dauer × Grundlast. Filament braucht eine Spule.

      @@ -188,11 +213,11 @@

      Grundlast = Elektronik/Motion. Düse/Bett 0 = nur pauschal. Mit HT/NT gewichtet Grotti die Stromkosten nach Uhrzeit.

      -
      +

      -
      +

      Benutzer

      Rollen: Admin (alles) und Operator (Drucker, Queue, Material — keine Benutzerverwaltung).

      @@ -213,7 +238,7 @@

      -
      +

      API-Tokens

      Für App, Home-Automation oder MCP. Klartext erscheint nur einmal bei der Erstellung. Authorization: Bearer grt_…

      @@ -234,6 +259,38 @@
      + +
      +
      + + +

      Drucker

      + +
      +
      +
      +
      + +
      +
      +
      + +
      +
      +
      +
      +
      +
      + + + +
      + +
      {% endblock %} {% block script %} {% endblock %} diff --git a/web/templates/ideen.html b/web/templates/ideen.html deleted file mode 100644 index c0cf4e6..0000000 --- a/web/templates/ideen.html +++ /dev/null @@ -1,112 +0,0 @@ -{% extends "layout.html" %} -{% block body %} -
      -
      -

      Ideen

      -

      Backlog und Notizen für später.

      -
      -
      -
      -
      - - - -
      TitelStand
      -
      -
      -

      Neue Idee

      -
      - -
      -
      -
      -
      -
      - - - -
      -
      -

      -
      -
      -{% endblock %} -{% block script %} - -{% endblock %} diff --git a/web/templates/jetzt.html b/web/templates/jetzt.html new file mode 100644 index 0000000..a7d8448 --- /dev/null +++ b/web/templates/jetzt.html @@ -0,0 +1,12 @@ +{% extends "layout.html" %} +{% block body %} +
      +
      +

      Jetzt

      +

      Handlungen, laufende Drucke und Meldungen.

      +
      +
      +
      + {% include "_live_panel.html" %} +
      +{% endblock %} diff --git a/web/templates/kameras.html b/web/templates/kameras.html index fac59e6..084a89c 100644 --- a/web/templates/kameras.html +++ b/web/templates/kameras.html @@ -5,7 +5,16 @@

      Kameras

      Livebilder aller Drucker.

      - +
      + + +
      {% endblock %} @@ -13,10 +22,27 @@ - + + {% block script %}{% endblock %} diff --git a/web/templates/login.html b/web/templates/login.html index b959812..d8471af 100644 --- a/web/templates/login.html +++ b/web/templates/login.html @@ -6,11 +6,11 @@ Anmelden · Grotti - +
      -

      Grotti

      +

      Grotti