Grotti 3.2.0: Jetzt-Liste, Filament-Pause, Strom-Torte, UI-Polish.
Jetzt/Topbar und Material-Routing, Pause bei Filament-Ende, Druckbericht mit Stromanteilen, Design- und Kamera-Stream-Polish; Bedarf-Alert-Spam und Platte-belegt-Bugs behoben.
This commit is contained in:
1 parent
6e59df22e5
commit
4ddffbbeb1
68 files changed
+4348
-1215
No files matched your search
+351
-356
@@ -9,7 +9,7 @@
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='brand/favicon-32.png') }}">
|
||||
<link rel="icon" type="image/png" sizes="512x512" href="{{ url_for('static', filename='brand/favicon.png') }}">
|
||||
<link rel="apple-touch-icon" href="{{ url_for('static', filename='brand/apple-touch-icon.png') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=56">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='grotti.css') }}?v=66">
|
||||
<link rel="manifest" href="{{ url_for('static', filename='manifest.webmanifest') }}">
|
||||
<meta name="theme-color" content="#0a0a0c" id="theme-color-meta">
|
||||
<script>
|
||||
@@ -65,119 +65,66 @@
|
||||
</head>
|
||||
<body class="app-shell">
|
||||
<div id="conn-banner" class="conn-banner" hidden role="status"></div>
|
||||
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
|
||||
<symbol id="i-printer" viewBox="0 0 24 24"><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><path d="M6 9V3h12v6"/><rect x="6" y="14" width="12" height="8" rx="1"/></symbol>
|
||||
<symbol id="i-camera" viewBox="0 0 24 24"><path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"/><circle cx="12" cy="13" r="3"/></symbol>
|
||||
<symbol id="i-file" viewBox="0 0 24 24"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/></symbol>
|
||||
<symbol id="i-spool" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="3"/><path d="M12 3v3M12 18v3M3 12h3M18 12h3"/></symbol>
|
||||
<symbol id="i-folder" viewBox="0 0 24 24"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2z"/></symbol>
|
||||
<symbol id="i-folder-open" viewBox="0 0 24 24"><path d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2"/></symbol>
|
||||
<symbol id="i-list" viewBox="0 0 24 24"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></symbol>
|
||||
<symbol id="i-activity" viewBox="0 0 24 24"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></symbol>
|
||||
<symbol id="i-clock" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></symbol>
|
||||
<symbol id="i-check" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></symbol>
|
||||
<symbol id="i-alert" viewBox="0 0 24 24"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4M12 17h.01"/></symbol>
|
||||
<symbol id="i-flame" viewBox="0 0 24 24"><path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"/></symbol>
|
||||
<symbol id="i-pause" viewBox="0 0 24 24"><rect x="14" y="4" width="4" height="16" rx="1"/><rect x="6" y="4" width="4" height="16" rx="1"/></symbol>
|
||||
<symbol id="i-play" viewBox="0 0 24 24"><path d="M5 3v18l15-9z"/></symbol>
|
||||
<symbol id="i-stop" viewBox="0 0 24 24"><rect x="5" y="5" width="14" height="14" rx="2"/></symbol>
|
||||
<symbol id="i-layers" viewBox="0 0 24 24"><path d="m12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z"/><path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"/><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"/></symbol>
|
||||
<symbol id="i-x" viewBox="0 0 24 24"><path d="M18 6 6 18M6 6l12 12"/></symbol>
|
||||
<symbol id="i-up" viewBox="0 0 24 24"><path d="m18 15-6-6-6 6"/></symbol>
|
||||
<symbol id="i-down" viewBox="0 0 24 24"><path d="m6 9 6 6 6-6"/></symbol>
|
||||
<symbol id="i-upload" viewBox="0 0 24 24"><path d="M12 15V3"/><path d="m7 8 5-5 5 5"/><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/></symbol>
|
||||
<symbol id="i-refresh" viewBox="0 0 24 24"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/></symbol>
|
||||
<symbol id="i-queue" viewBox="0 0 24 24"><path d="M3 5h12"/><path d="M3 12h9"/><path d="M3 19h6"/><path d="m16 11 5 3-5 3z"/></symbol>
|
||||
<symbol id="i-send" viewBox="0 0 24 24"><path d="m22 2-7 20-4-9-9-4z"/><path d="M22 2 11 13"/></symbol>
|
||||
<symbol id="i-book" viewBox="0 0 24 24"><path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H19a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H6.5A2.5 2.5 0 0 1 4 19.5"/><path d="M8 7h8M8 11h8"/></symbol>
|
||||
<symbol id="i-cube" viewBox="0 0 24 24"><path d="m21 8-9-5-9 5v8l9 5 9-5z"/><path d="m3.3 7.2 8.7 4.8 8.7-4.8"/><path d="M12 22V12"/></symbol>
|
||||
<symbol id="i-bell" viewBox="0 0 24 24"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></symbol>
|
||||
<symbol id="i-sweep" viewBox="0 0 24 24"><path d="M3 21h18"/><path d="M9 8v8"/><path d="M15 8v8"/><path d="M5 12h14"/><path d="M7 16h10"/><path d="M12 3v5"/></symbol>
|
||||
<symbol id="i-thermo" viewBox="0 0 24 24"><path d="M14 4v10.54a4 4 0 1 1-4 0V4a2 2 0 0 1 4 0"/><path d="M12 14v3"/></symbol>
|
||||
<symbol id="i-settings" viewBox="0 0 24 24"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></symbol>
|
||||
<symbol id="i-bulb" viewBox="0 0 24 24"><path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5"/><path d="M9 18h6M10 22h4"/></symbol>
|
||||
<symbol id="i-search" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></symbol>
|
||||
<symbol id="i-filter" viewBox="0 0 24 24"><path d="M22 3H2l8 9.46V19l4 2v-8.54z"/></symbol>
|
||||
<symbol id="i-info" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/></symbol>
|
||||
<symbol id="i-scan" viewBox="0 0 24 24"><path d="M3 7V5a2 2 0 0 1 2-2h2"/><path d="M17 3h2a2 2 0 0 1 2 2v2"/><path d="M21 17v2a2 2 0 0 1-2 2h-2"/><path d="M7 21H5a2 2 0 0 1-2-2v-2"/><rect x="7" y="7" width="10" height="10" rx="1"/></symbol>
|
||||
<symbol id="i-trash" viewBox="0 0 24 24"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M10 11v6M14 11v6"/></symbol>
|
||||
<symbol id="i-inbox" viewBox="0 0 24 24"><path d="M22 12h-6l-2 3h-4l-2-3H2"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></symbol>
|
||||
<symbol id="i-image" viewBox="0 0 24 24"><rect width="18" height="18" x="3" y="3" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></symbol>
|
||||
<symbol id="i-grid" viewBox="0 0 24 24"><rect width="7" height="7" x="3" y="3" rx="1"/><rect width="7" height="7" x="14" y="3" rx="1"/><rect width="7" height="7" x="14" y="14" rx="1"/><rect width="7" height="7" x="3" y="14" rx="1"/></symbol>
|
||||
<symbol id="i-menu" viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h16"/></symbol>
|
||||
<symbol id="i-panel" viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="18" rx="1"/><path d="M14 8h7M14 12h7M14 16h7"/></symbol>
|
||||
<symbol id="i-nozzle" viewBox="0 0 24 24"><path d="M8 3h8v4l-2 3v5l-2 6-2-6v-5L8 7z"/><path d="M9 7h6"/></symbol>
|
||||
<symbol id="i-bed" viewBox="0 0 24 24"><path d="M4 16h16"/><path d="M5 16v3M19 16v3"/><path d="M6 12h12v4H6z"/><path d="M8 8h8"/></symbol>
|
||||
<symbol id="i-chamber" viewBox="0 0 24 24"><rect x="4" y="3" width="16" height="18" rx="2"/><path d="M8 8h8M8 12h8M8 16h5"/></symbol>
|
||||
<symbol id="i-fan" viewBox="0 0 24 24"><circle cx="12" cy="12" r="2"/><path d="M12 4c2 2 2 5 0 6-2-1-2-4 0-6zM12 14c2 1 2 4 0 6-2-2-2-5 0-6zM4 12c2-2 5-2 6 0-1 2-4 2-6 0zM14 12c1-2 4-2 6 0-2 2-5 2-6 0z"/></symbol>
|
||||
<symbol id="i-fan-chamber" viewBox="0 0 24 24"><circle cx="12" cy="12" r="1.6"/><path d="M12 5.5c1.4 1.4 1.4 3.5 0 4.2-1.4-.7-1.4-2.8 0-4.2zM12 14.3c1.4.7 1.4 2.8 0 4.2-1.4-1.4-1.4-3.5 0-4.2zM5.5 12c1.4-1.4 3.5-1.4 4.2 0-.7 1.4-2.8 1.4-4.2 0zM14.3 12c.7-1.4 2.8-1.4 4.2 0-1.4 1.4-3.5 1.4-4.2 0z"/><rect x="3.5" y="3.5" width="17" height="17" rx="2" fill="none"/></symbol>
|
||||
<symbol id="i-fan-aux" viewBox="0 0 24 24"><circle cx="12" cy="12" r="2"/><path d="M12 3v3M12 18v3M3 12h3M18 12h3M5.6 5.6l2.1 2.1M16.3 16.3l2.1 2.1M18.4 5.6l-2.1 2.1M7.7 16.3l-2.1 2.1"/></symbol>
|
||||
</svg>
|
||||
{{ icons_sprite }}
|
||||
<aside class="sidebar">
|
||||
<div class="brand-row">
|
||||
<a class="brand" href="/" title="Grotti">
|
||||
<img class="brand-mark" src="{{ url_for('static', filename='brand/logo.png') }}" alt="" width="28" height="28" decoding="async">
|
||||
<span class="brand-name">Grotti</span>
|
||||
</a>
|
||||
<button type="button" class="icon-btn nav-toggle" id="nav-toggle" title="Menü einklappen" aria-label="Menü einklappen" aria-pressed="false"><svg class="ico"><use href="#i-panel"/></svg></button>
|
||||
<div class="brand-tools">
|
||||
<button type="button" class="icon-btn m-only" id="cmdk-open-m" title="Suche (Ctrl+K)" aria-label="Suche öffnen"><svg class="ico"><use href="#i-search"/></svg></button>
|
||||
<button type="button" class="icon-btn nav-toggle" id="nav-toggle" title="Menü einklappen" aria-label="Menü einklappen" aria-pressed="false"><svg class="ico"><use href="#i-panel"/></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="nav-main" aria-label="Hauptmenü">
|
||||
<span class="nav-label">Live</span>
|
||||
<a href="/" class="{{ 'on' if page == 'wand' else '' }}" title="Drucker"><svg class="ico"><use href="#i-printer"/></svg><span>Drucker</span></a>
|
||||
<a href="/dateien" class="{{ 'on' if page == 'dateien' else '' }} nav-desk" title="Dateien"><svg class="ico"><use href="#i-file"/></svg><span>Dateien</span></a>
|
||||
<a href="/kameras" class="{{ 'on' if page == 'kameras' else '' }}" title="Kameras"><svg class="ico"><use href="#i-camera"/></svg><span>Kameras</span></a>
|
||||
<a href="/warteschlange" class="{{ 'on' if page == 'warteschlange' else '' }}" title="Warteschlange"><svg class="ico"><use href="#i-queue"/></svg><span>Warteschlange</span></a>
|
||||
<a href="/auftraege" class="{{ 'on' if page == 'auftraege' else '' }} nav-desk" title="Aufträge"><svg class="ico"><use href="#i-folder"/></svg><span>Aufträge</span></a>
|
||||
<span class="nav-label nav-desk">Rückschau</span>
|
||||
<a href="/protokoll" class="{{ 'on' if page == 'protokoll' or page == 'bericht' else '' }} nav-desk" title="Protokoll"><svg class="ico"><use href="#i-list"/></svg><span>Protokoll</span></a>
|
||||
<a href="/statistik" class="{{ 'on' if page == 'statistik' else '' }} nav-desk" title="Statistik"><svg class="ico"><use href="#i-grid"/></svg><span>Statistik</span></a>
|
||||
<a href="/statistik" class="{{ 'on' if page == 'statistik' else '' }} nav-desk" title="Statistik"><svg class="ico"><use href="#i-chart"/></svg><span>Statistik</span></a>
|
||||
<a href="/aktivitaet" class="{{ 'on' if page == 'aktivitaet' else '' }} nav-desk" title="Aktivität"><svg class="ico"><use href="#i-activity"/></svg><span>Aktivität</span></a>
|
||||
<span class="nav-label nav-desk">Verwalten</span>
|
||||
<a href="/material" class="{{ 'on' if page == 'material' else '' }} nav-desk" title="Material"><svg class="ico"><use href="#i-spool"/></svg><span>Material</span></a>
|
||||
<a href="/wartung" class="{{ 'on' if page == 'wartung' else '' }} nav-desk" title="Wartung"><svg class="ico"><use href="#i-nozzle"/></svg><span>Wartung</span></a>
|
||||
<a href="/wartung" class="{{ 'on' if page == 'wartung' else '' }} nav-desk" title="Wartung"><svg class="ico"><use href="#i-wrench"/></svg><span>Wartung</span></a>
|
||||
</nav>
|
||||
<div class="live-panel" id="live-panel">
|
||||
<details class="queue-box" data-box="run" id="run-box" open hidden>
|
||||
<summary class="queue-title"><svg class="ico caret"><use href="#i-down"/></svg><span class="grow">Läuft jetzt</span><span class="count hot" id="run-count">0</span></summary>
|
||||
<ul class="queue-list" id="run-list"></ul>
|
||||
</details>
|
||||
<details class="queue-box" data-box="wait" id="wait-box" open>
|
||||
<summary class="queue-title"><svg class="ico caret"><use href="#i-down"/></svg><span class="grow">Wartet</span><span class="count" id="wait-count">0</span></summary>
|
||||
<ul class="queue-list" id="wait-list"></ul>
|
||||
<div class="queue-empty" id="wait-empty"><svg class="ico"><use href="#i-inbox"/></svg><span>Leer</span></div>
|
||||
</details>
|
||||
<details class="queue-box" data-box="todo" id="todo-box" open hidden>
|
||||
<summary class="queue-title"><svg class="ico caret"><use href="#i-down"/></svg><span class="grow">Aufgaben</span><span class="count todo" id="todo-count">0</span></summary>
|
||||
<ul class="queue-list" id="todo-list"></ul>
|
||||
</details>
|
||||
<details class="queue-box" data-box="send" id="send-box" open hidden>
|
||||
<summary class="queue-title"><svg class="ico caret"><use href="#i-down"/></svg><span class="grow">Senden</span><span class="count" id="send-count">0</span></summary>
|
||||
<ul class="queue-list" id="send-list"></ul>
|
||||
</details>
|
||||
<details class="queue-box" data-box="alerts" id="alert-box" open hidden>
|
||||
<summary class="queue-title"><svg class="ico caret"><use href="#i-down"/></svg><span class="grow">Alerts</span><span class="count bad" id="alert-count">0</span></summary>
|
||||
<ul class="queue-list" id="alert-list"></ul>
|
||||
<div class="queue-foot"><button type="button" class="ghost tiny-btn" id="events-clear">gelesen</button> <a href="/aktivitaet">alle →</a></div>
|
||||
</details>
|
||||
<details class="queue-box" data-box="bedarf" id="bedarf-box" open hidden>
|
||||
<summary class="queue-title"><svg class="ico caret"><use href="#i-down"/></svg><span class="grow">Bedarf</span><span class="count bad" id="bedarf-count">0</span></summary>
|
||||
<ul class="queue-list" id="bedarf-list"></ul>
|
||||
<div class="queue-foot"><a href="/material#bedarf">Material →</a></div>
|
||||
</details>
|
||||
</div>
|
||||
<nav class="nav-secondary" aria-label="Weitere Seiten">
|
||||
<a href="/einstellungen" class="{{ 'on' if page == 'einstellungen' else '' }}" title="Einstellungen"><svg class="ico"><use href="#i-settings"/></svg><span>Einstellungen</span></a>
|
||||
<a href="/doku" class="{{ 'on' if page == 'doku' else '' }}" title="Doku"><svg class="ico"><use href="#i-book"/></svg><span>Doku</span></a>
|
||||
</nav>
|
||||
<div class="side-foot">
|
||||
{% if current_user %}
|
||||
<div class="side-user">
|
||||
<span class="tiny" title="{{ current_user.role }}">{{ current_user.username }}</span>
|
||||
<button type="button" class="ghost tiny-btn" id="logout-btn">Abmelden</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
<span class="side-copy">© by <a href="mailto:[email protected]">[email protected]</a></span>
|
||||
<a href="/doku" title="Doku">v{{ version }}</a>
|
||||
</div>
|
||||
</aside>
|
||||
<div class="app-main">
|
||||
<header class="topbar">
|
||||
<button type="button" class="cmdk-btn" id="cmdk-open" title="Suche (Ctrl+K)" aria-label="Suche öffnen"><svg class="ico"><use href="#i-search"/></svg><span>Suche</span><kbd>Ctrl K</kbd></button>
|
||||
<div class="live-bar" id="live-bar">
|
||||
<div class="live-chips" id="live-chips"></div>
|
||||
{% if page != 'jetzt' %}
|
||||
<div class="live-pop" id="live-pop" hidden>
|
||||
<div class="live-panel" id="live-panel">
|
||||
{% include "_live_panel.html" %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="topbar-tools">
|
||||
<a class="icon-btn {{ 'on' if page == 'einstellungen' else '' }}" href="/einstellungen" title="Einstellungen" aria-label="Einstellungen"><svg class="ico"><use href="#i-settings"/></svg></a>
|
||||
<a class="icon-btn {{ 'on' if page == 'doku' else '' }}" href="/doku" title="Doku" aria-label="Doku"><svg class="ico"><use href="#i-book"/></svg></a>
|
||||
{% if current_user %}
|
||||
<details class="user-menu" id="user-menu">
|
||||
<summary class="side-avatar" aria-label="Benutzer-Menü">{{ current_user.username[:1]|upper }}</summary>
|
||||
<div class="menu-pop" role="menu">
|
||||
<div class="menu-user">
|
||||
<strong>{{ current_user.username }}</strong>
|
||||
<span class="tiny">{{ current_user.role }}</span>
|
||||
</div>
|
||||
<button type="button" class="ghost" id="logout-btn" role="menuitem">Abmelden</button>
|
||||
<a href="/doku" role="menuitem">v{{ version }}</a>
|
||||
<span class="tiny menu-copy">© by <a href="mailto:[email protected]">[email protected]</a></span>
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
{% block body %}{% endblock %}
|
||||
</main>
|
||||
@@ -185,8 +132,8 @@
|
||||
<nav class="mobile-nav" aria-label="Schnellmenü">
|
||||
<a href="/" class="{{ 'on' if page == 'wand' else '' }}"><svg class="ico"><use href="#i-printer"/></svg><span>Drucker</span></a>
|
||||
<a href="/dateien" class="{{ 'on' if page == 'dateien' else '' }}"><svg class="ico"><use href="#i-file"/></svg><span>Dateien</span></a>
|
||||
<a href="/warteschlange" class="{{ 'on' if page == 'warteschlange' else '' }}"><svg class="ico"><use href="#i-queue"/></svg><span>Queue</span></a>
|
||||
<a href="/aktivitaet" class="{{ 'on' if page == 'aktivitaet' else '' }}"><svg class="ico"><use href="#i-activity"/></svg><span>Aktivität</span></a>
|
||||
<a href="/warteschlange" class="{{ 'on' if page == 'warteschlange' else '' }}"><svg class="ico"><use href="#i-queue"/></svg><span>Warteschlange</span></a>
|
||||
<a href="/jetzt" class="{{ 'on' if page == 'jetzt' else '' }}"><svg class="ico"><use href="#i-sparkles"/></svg><span>Jetzt</span><span class="nav-badge" id="mobile-jetzt-badge" hidden>0</span></a>
|
||||
<button type="button" class="mobile-more-btn" id="mobile-more" aria-expanded="false" aria-controls="mobile-more-sheet"><svg class="ico"><use href="#i-menu"/></svg><span>Mehr</span></button>
|
||||
</nav>
|
||||
<dialog id="mobile-more-sheet" class="mobile-more-sheet">
|
||||
@@ -195,20 +142,45 @@
|
||||
<a href="/auftraege">Aufträge</a>
|
||||
<a href="/protokoll">Protokoll</a>
|
||||
<a href="/statistik">Statistik</a>
|
||||
<a href="/aktivitaet">Aktivität</a>
|
||||
<a href="/material">Material</a>
|
||||
<a href="/wartung">Wartung</a>
|
||||
<a href="/einstellungen">Einstellungen</a>
|
||||
<a href="/doku">Doku</a>
|
||||
</nav>
|
||||
{% if current_user %}
|
||||
<div class="mobile-more-user">
|
||||
<span class="side-avatar" aria-hidden="true">{{ current_user.username[:1]|upper }}</span>
|
||||
<span class="grow">
|
||||
<strong>{{ current_user.username }}</strong>
|
||||
<span class="tiny">{{ current_user.role }} · v{{ version }}</span>
|
||||
</span>
|
||||
<button type="button" class="ghost tiny-btn" id="logout-btn-m">Abmelden</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
<form method="dialog"><button class="ghost" value="cancel">Schließen</button></form>
|
||||
</dialog>
|
||||
<script src="{{ url_for('static', filename='api.js') }}?v=29"></script>
|
||||
<script src="{{ url_for('static', filename='ui.js') }}?v=3"></script>
|
||||
<script src="{{ url_for('static', filename='api.js') }}?v=32"></script>
|
||||
<script src="{{ url_for('static', filename='ui.js') }}?v=5"></script>
|
||||
<script>
|
||||
document.getElementById("logout-btn")?.addEventListener("click", async () => {
|
||||
async function doLogout() {
|
||||
try { await api("/auth/logout", { method: "POST", json: {} }); } catch (e) {}
|
||||
location.href = "/login";
|
||||
});
|
||||
}
|
||||
document.getElementById("logout-btn")?.addEventListener("click", doLogout);
|
||||
document.getElementById("logout-btn-m")?.addEventListener("click", doLogout);
|
||||
(function userMenuClose() {
|
||||
const menu = document.getElementById("user-menu");
|
||||
if (!menu) return;
|
||||
document.addEventListener("click", (event) => {
|
||||
if (!menu.open) return;
|
||||
if (menu.contains(event.target)) return;
|
||||
menu.open = false;
|
||||
});
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape" && menu.open) menu.open = false;
|
||||
});
|
||||
})();
|
||||
(function applyShellClasses() {
|
||||
const shelf = document.documentElement.classList.contains("shelf");
|
||||
const collapsed = document.documentElement.classList.contains("nav-collapsed");
|
||||
@@ -247,6 +219,11 @@ function setDisplayMode(mode) {
|
||||
document.getElementById("nav-toggle")?.addEventListener("click", () => {
|
||||
setNavCollapsed(!document.body.classList.contains("nav-collapsed"));
|
||||
});
|
||||
function openCmdk() {
|
||||
if (typeof openCommandPalette === "function") openCommandPalette();
|
||||
}
|
||||
document.getElementById("cmdk-open")?.addEventListener("click", openCmdk);
|
||||
document.getElementById("cmdk-open-m")?.addEventListener("click", openCmdk);
|
||||
|
||||
(function restoreBoxes() {
|
||||
let closed = [];
|
||||
@@ -267,33 +244,74 @@ function queueTarget(job) {
|
||||
|
||||
async function cancelQueueItem(job, button) {
|
||||
const running = job.status === "running";
|
||||
const armed = running
|
||||
? (job.is_ablauf ? "Ablauf stoppen?" : "Auftrag stoppen?")
|
||||
: "Entfernen?";
|
||||
const go = async () => {
|
||||
if (button) button.disabled = true;
|
||||
const prev = { status: job.status, priority: job.priority };
|
||||
try {
|
||||
await api("/druck-queue/" + job.id, { method: "PATCH", json: { status: "cancelled" } });
|
||||
toast(running ? "Abgebrochen." : "Aus der Warteschlange genommen.");
|
||||
if (running) {
|
||||
toast("Abgebrochen.");
|
||||
} else if (typeof undoToast === "function") {
|
||||
undoToast("Aus der Warteschlange genommen.", async () => {
|
||||
await api("/druck-queue/" + job.id, {
|
||||
method: "PATCH",
|
||||
json: { status: "waiting", priority: prev.priority, undo: true },
|
||||
});
|
||||
toast("Wieder eingereiht.");
|
||||
refreshJetzt();
|
||||
if (typeof window.onQueueChanged === "function") window.onQueueChanged();
|
||||
});
|
||||
} else {
|
||||
toast("Aus der Warteschlange genommen.");
|
||||
}
|
||||
} catch (error) {
|
||||
toast(error.message, "bad");
|
||||
}
|
||||
refreshPrintQueue();
|
||||
refreshJetzt();
|
||||
if (typeof window.onQueueChanged === "function") window.onQueueChanged();
|
||||
if (button) button.disabled = false;
|
||||
};
|
||||
if (button) armButton(button, armed, go);
|
||||
if (running && button) armButton(button, job.is_ablauf ? "Ablauf stoppen?" : "Druck stoppen?", go);
|
||||
else go();
|
||||
}
|
||||
window.cancelQueueItem = cancelQueueItem;
|
||||
|
||||
function ablaufStepFrac(job) {
|
||||
if (!job || !job.is_ablauf || !job.step_labels || !job.step_labels.length) return "";
|
||||
const idx = Number(job.display_step_index != null ? job.display_step_index : job.step_index) || 0;
|
||||
return (idx + 1) + "/" + job.step_labels.length;
|
||||
function setListHtml(el, html) {
|
||||
if (!el) return;
|
||||
if (el._html === html) return;
|
||||
el._html = html;
|
||||
el.innerHTML = html;
|
||||
}
|
||||
|
||||
function runLineHtml({ name, progress, remain, filename, tipExtra, cancelId }) {
|
||||
function shortPrinter(name) {
|
||||
let text = String(name || "").replace(/^Kobra\s+(?:3\s+)?/i, "").trim() || String(name || "");
|
||||
return text.length > 12 ? text.slice(0, 10) + "…" : text;
|
||||
}
|
||||
|
||||
function openLivePop(section) {
|
||||
const pop = document.getElementById("live-pop");
|
||||
if (!pop) {
|
||||
const box = section ? document.querySelector(`[data-box="${section}"]`) : null;
|
||||
if (box) {
|
||||
box.scrollIntoView({ block: "start", behavior: "smooth" });
|
||||
return;
|
||||
}
|
||||
location.href = section ? "/jetzt#" + encodeURIComponent(section) : "/jetzt";
|
||||
return;
|
||||
}
|
||||
pop.hidden = false;
|
||||
if (section) {
|
||||
const box = pop.querySelector(`[data-box="${section}"]`);
|
||||
if (box) box.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
}
|
||||
|
||||
function closeLivePop() {
|
||||
const pop = document.getElementById("live-pop");
|
||||
if (pop) pop.hidden = true;
|
||||
}
|
||||
|
||||
function runLineHtml({ name, progress, remain, filename, tipExtra, cancelId, host }) {
|
||||
const pct = Math.max(0, Math.min(100, Math.round((Number(progress) || 0) * 100)));
|
||||
const finish = remain > 0 ? finishClock(remain) : "";
|
||||
const bits = [name];
|
||||
@@ -305,8 +323,9 @@ function runLineHtml({ name, progress, remain, filename, tipExtra, cancelId }) {
|
||||
remain > 0 ? "noch " + hours(remain) : "",
|
||||
tipExtra ? "Schritt " + tipExtra : "",
|
||||
].filter(Boolean).join(" · ");
|
||||
const href = host ? "/#" + encodeURIComponent(host) : "/";
|
||||
return `<li class="queue-item running run-line" style="--pct:${pct}">
|
||||
<a class="queue-link run-line-link" href="/warteschlange" title="${escapeHtml(tip || "Zur Warteschlange")}">
|
||||
<a class="queue-link run-line-link" href="${escapeHtml(href)}" title="${escapeHtml(tip || name || "Drucker")}">
|
||||
<span class="run-line-fill" aria-hidden="true"></span>
|
||||
<span class="run-line-text">${escapeHtml(bits.join(" · "))}</span>
|
||||
</a>
|
||||
@@ -314,233 +333,74 @@ function runLineHtml({ name, progress, remain, filename, tipExtra, cancelId }) {
|
||||
</li>`;
|
||||
}
|
||||
|
||||
function runItemHtml(job) {
|
||||
return runLineHtml({
|
||||
name: queueTarget(job),
|
||||
progress: job.progress,
|
||||
remain: job.remain,
|
||||
filename: job.filename,
|
||||
tipExtra: ablaufStepFrac(job),
|
||||
cancelId: job.id,
|
||||
});
|
||||
}
|
||||
|
||||
function waitItemHtml(job, index, total) {
|
||||
const isError = job.status === "error";
|
||||
const label = job.is_ablauf ? "Ablauf · " + jobLabel(job.filename) : jobLabel(job.filename);
|
||||
const rep = job.is_ablauf && (job.repeat_total || 1) > 1 ? job.repeat_total + "×" : "";
|
||||
return `<li class="queue-item ${isError ? "error" : ""}">
|
||||
function actionItemHtml(action) {
|
||||
const kind = action.kind || "";
|
||||
const cls = kind === "queue_error" || kind === "bedarf" || kind === "wartung" || kind === "auftrag" || kind === "filament_pause" ? "error" : "";
|
||||
let tools = "";
|
||||
if (kind === "clear" && action.host) {
|
||||
tools = `<span class="queue-tools"><button type="button" class="icon-btn done-btn" data-cleared="${escapeHtml(action.host)}" title="Platte geräumt" aria-label="Platte geräumt">${icon("check")}</button></span>`;
|
||||
} else if (kind === "queue_error" && action.id) {
|
||||
if (action.is_ablauf) {
|
||||
tools = `<span class="queue-tools"><button type="button" class="icon-btn" data-resume-q="${action.id}" title="Fortsetzen">${icon("play")}</button>
|
||||
<button type="button" class="icon-btn danger" data-dismiss-q="${action.id}" title="Ausblenden">${icon("x")}</button></span>`;
|
||||
} else {
|
||||
tools = `<span class="queue-tools"><button type="button" class="icon-btn danger" data-dismiss-q="${action.id}" title="Ausblenden">${icon("x")}</button></span>`;
|
||||
}
|
||||
} else if (kind === "waiting" && action.id) {
|
||||
tools = `<span class="queue-tools"><button type="button" class="icon-btn danger" data-cancel-q="${action.id}" title="Entfernen">${icon("x")}</button></span>`;
|
||||
}
|
||||
const href = action.href || "/warteschlange";
|
||||
const name = action.printer || action.title || "";
|
||||
const detail = action.detail || action.filename || "";
|
||||
return `<li class="queue-item ${cls}">
|
||||
<div class="queue-row">
|
||||
<span class="grow"><span class="queue-name" title="${escapeHtml(job.filename)}">${escapeHtml(label)}</span></span>
|
||||
<span class="queue-tools">
|
||||
${!isError && index > 0 ? `<button type="button" class="icon-btn" data-prio-q="${job.id}" data-dir="-1" title="Höher">${icon("up")}</button>` : ""}
|
||||
${!isError && index < total - 1 ? `<button type="button" class="icon-btn" data-prio-q="${job.id}" data-dir="1" title="Tiefer">${icon("down")}</button>` : ""}
|
||||
${isError && job.is_ablauf ? `<button type="button" class="icon-btn" data-resume-q="${job.id}" title="Ab Schritt ${(Number(job.step_index) || 0) + 1} (${escapeHtml(job.step_label_current || "")}) weitermachen">${icon("play")}</button>` : ""}
|
||||
<button type="button" class="icon-btn danger" ${isError ? `data-dismiss-q="${job.id}" title="Ausblenden"` : `data-cancel-q="${job.id}" title="Entfernen"`}>${icon("x")}</button>
|
||||
</span>
|
||||
<a class="queue-link grow" href="${escapeHtml(href)}"><span class="queue-name">${escapeHtml(action.title || name)}</span></a>
|
||||
${tools}
|
||||
</div>
|
||||
<span class="queue-sub">${isError ? "fehlgeschlagen · " : "→ "}${escapeHtml(queueTarget(job))}${rep ? " · " + rep : ""}</span>
|
||||
${isError ? `<span class="queue-msg">${escapeHtml(job.message || "")}</span>` : ""}
|
||||
<span class="queue-msg">${escapeHtml([name !== action.title ? name : "", detail].filter(Boolean).join(" · "))}</span>
|
||||
</li>`;
|
||||
}
|
||||
|
||||
function directItemHtml(p) {
|
||||
return runLineHtml({
|
||||
name: p.name || p.host,
|
||||
progress: p.progress,
|
||||
remain: p.remain,
|
||||
filename: p.filename,
|
||||
});
|
||||
}
|
||||
|
||||
let queueCache = [];
|
||||
const lastLiveFilename = Object.create(null);
|
||||
|
||||
function setListHtml(el, html) {
|
||||
if (!el) return;
|
||||
if (el._html === html) return;
|
||||
el._html = html;
|
||||
el.innerHTML = html;
|
||||
}
|
||||
|
||||
function stickyLiveFilename(host, filename, state) {
|
||||
const next = filename || "";
|
||||
if (next) {
|
||||
lastLiveFilename[host] = next;
|
||||
return next;
|
||||
}
|
||||
const prev = lastLiveFilename[host] || "";
|
||||
if (prev && ["printing", "paused", "heating", "complete"].includes(String(state || "").toLowerCase())) {
|
||||
return prev;
|
||||
}
|
||||
if (!["printing", "paused", "heating"].includes(String(state || "").toLowerCase())) {
|
||||
delete lastLiveFilename[host];
|
||||
}
|
||||
return prev && ["complete"].includes(String(state || "").toLowerCase()) ? prev : "";
|
||||
}
|
||||
|
||||
async function refreshPrintQueue() {
|
||||
let jobs;
|
||||
let active = [];
|
||||
try {
|
||||
[jobs, active] = await Promise.all([api("/druck-queue"), api("/laeuft").catch(() => [])]);
|
||||
} catch (error) { return; }
|
||||
queueCache = jobs || [];
|
||||
const liveByHost = Object.create(null);
|
||||
(active || []).forEach((p) => { if (p.host) liveByHost[p.host] = p; });
|
||||
const running = queueCache.filter((job) => job.status === "running").map((job) => {
|
||||
const live = liveByHost[job.host];
|
||||
if (!live) return job;
|
||||
return {
|
||||
...job,
|
||||
progress: live.progress,
|
||||
remain: live.remain,
|
||||
filename: stickyLiveFilename(job.host, live.filename || job.filename, live.state) || job.filename,
|
||||
};
|
||||
});
|
||||
const queuedHosts = new Set(running.map((job) => job.host));
|
||||
const direct = (active || []).filter((p) => !queuedHosts.has(p.host)).map((p) => ({
|
||||
...p,
|
||||
filename: stickyLiveFilename(p.host, p.filename, p.state),
|
||||
}));
|
||||
const waiting = queueCache.filter((job) => job.status === "waiting");
|
||||
const errors = queueCache
|
||||
.filter((job) => job.status === "error")
|
||||
.sort((a, b) => String(b.updated_at).localeCompare(String(a.updated_at)))
|
||||
.slice(0, 2);
|
||||
|
||||
const runBox = document.getElementById("run-box");
|
||||
runBox.hidden = !(running.length || direct.length);
|
||||
document.getElementById("run-count").textContent = running.length + direct.length;
|
||||
setListHtml(
|
||||
document.getElementById("run-list"),
|
||||
running.map(runItemHtml).join("") + direct.map(directItemHtml).join(""),
|
||||
);
|
||||
|
||||
const waitList = document.getElementById("wait-list");
|
||||
document.getElementById("wait-count").textContent = waiting.length;
|
||||
document.getElementById("wait-count").className = "count" + (errors.length ? " bad" : waiting.length ? " hot" : "");
|
||||
document.getElementById("wait-empty").hidden = !!(waiting.length || errors.length);
|
||||
document.getElementById("wait-box").classList.toggle("is-empty", !(waiting.length || errors.length));
|
||||
setListHtml(
|
||||
waitList,
|
||||
errors.map((job) => waitItemHtml(job, 0, 1)).join("")
|
||||
+ waiting.map((job, i) => waitItemHtml(job, i, waiting.length)).join(""),
|
||||
);
|
||||
|
||||
const panel = document.getElementById("live-panel");
|
||||
panel.querySelectorAll("[data-cancel-q]").forEach((btn) => {
|
||||
btn.onclick = () => {
|
||||
const job = queueCache.find((item) => item.id === Number(btn.dataset.cancelQ));
|
||||
if (job) cancelQueueItem(job, btn);
|
||||
};
|
||||
});
|
||||
panel.querySelectorAll("[data-resume-q]").forEach((btn) => {
|
||||
btn.onclick = () => {
|
||||
const job = queueCache.find((item) => item.id === Number(btn.dataset.resumeQ));
|
||||
if (!job) return;
|
||||
const step = job.step_label_current || ("Schritt " + ((job.step_index || 0) + 1));
|
||||
armButton(btn, "Weiter ab „" + step + "“?", async () => {
|
||||
btn.disabled = true;
|
||||
try {
|
||||
await api("/druck-queue/" + job.id, { method: "PATCH", json: { status: "running", resume: true } });
|
||||
toast("Ablauf geht weiter.");
|
||||
} catch (error) { toast(error.message, "bad"); }
|
||||
refreshPrintQueue();
|
||||
if (typeof window.onQueueChanged === "function") window.onQueueChanged();
|
||||
btn.disabled = false;
|
||||
});
|
||||
};
|
||||
});
|
||||
panel.querySelectorAll("[data-dismiss-q]").forEach((btn) => {
|
||||
btn.onclick = () => armButton(btn, "Ausblenden?", async () => {
|
||||
try { await api("/druck-queue/" + btn.dataset.dismissQ, { method: "PATCH", json: { status: "dismissed" } }); } catch (error) { toast(error.message, "bad"); }
|
||||
refreshPrintQueue();
|
||||
});
|
||||
});
|
||||
panel.querySelectorAll("[data-prio-q]").forEach((btn) => {
|
||||
btn.onclick = async () => {
|
||||
const id = Number(btn.dataset.prioQ);
|
||||
const dir = Number(btn.dataset.dir);
|
||||
const idx = waiting.findIndex((job) => job.id === id);
|
||||
const other = waiting[idx + dir];
|
||||
if (!other) return;
|
||||
try {
|
||||
await api("/druck-queue/" + id, { method: "PATCH", json: { priority: Number(other.priority) + dir } });
|
||||
} catch (error) { toast(error.message, "bad"); }
|
||||
refreshPrintQueue();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshSendQueue() {
|
||||
let jobs;
|
||||
try { jobs = await api("/sende-auftraege"); } catch (error) { return; }
|
||||
const active = (jobs || []).filter((job) => job.status === "pending" || job.status === "running");
|
||||
const recent = active.length ? active : (jobs || []).slice(0, 2).filter((job) => job.status === "error");
|
||||
const box = document.getElementById("send-box");
|
||||
box.hidden = !recent.length;
|
||||
document.getElementById("send-count").textContent = active.length;
|
||||
document.getElementById("send-list").innerHTML = recent.map((job) => {
|
||||
const pct = Math.max(0, Math.min(100, Number(job.progress) || 0));
|
||||
const cls = job.status === "error" ? "bad" : job.status === "done" ? "good" : "";
|
||||
return `<li class="queue-item ${job.status === "error" ? "error" : ""}">
|
||||
<span class="queue-name">${escapeHtml(job.printer || "")}</span>
|
||||
<span class="queue-msg">${escapeHtml(job.message || "")}</span>
|
||||
<span class="send-progress-track slim"><span class="send-progress-bar ${cls}" style="width:${pct}%"></span></span>
|
||||
</li>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
async function refreshEvents() {
|
||||
let data;
|
||||
try { data = await api("/ereignisse?unread=1&limit=5"); } catch (error) { return; }
|
||||
const items = data.items || [];
|
||||
const unread = Number(data.unread != null ? data.unread : items.length);
|
||||
const box = document.getElementById("alert-box");
|
||||
box.hidden = !items.length;
|
||||
document.getElementById("alert-count").textContent = unread;
|
||||
document.getElementById("alert-list").innerHTML = items.map((item) => `<li class="queue-item">
|
||||
<div class="queue-row"><span class="grow"><span class="queue-name">${escapeHtml(item.printer || item.kind)}</span></span><span class="queue-sub">${escapeHtml(clockOf(item.created_at))}</span></div>
|
||||
<span class="queue-msg">${escapeHtml(item.message)}</span>
|
||||
</li>`).join("");
|
||||
}
|
||||
|
||||
async function refreshBedarf() {
|
||||
let data;
|
||||
try { data = await api("/bedarf"); } catch (error) { return; }
|
||||
const shortfall = data.shortfall || [];
|
||||
const box = document.getElementById("bedarf-box");
|
||||
function renderLiveChips({ actionItems, running, activeSends, alertCount, etaText }) {
|
||||
const box = document.getElementById("live-chips");
|
||||
if (!box) return;
|
||||
box.hidden = !shortfall.length;
|
||||
document.getElementById("bedarf-count").textContent = shortfall.length;
|
||||
document.getElementById("bedarf-list").innerHTML = shortfall.slice(0, 5).map((item) => {
|
||||
const label = [item.material_type, item.color].filter(Boolean).join(" · ") || "Filament";
|
||||
return `<li class="queue-item">
|
||||
<div class="queue-row"><span class="grow"><span class="queue-name">${escapeHtml(label)}</span></span>
|
||||
<span class="queue-sub">${grams(item.shortfall_g)} fehlen</span></div>
|
||||
<span class="queue-msg">${grams(item.weight_g)} Queue · ${grams(item.remaining_g)} Rest</span>
|
||||
</li>`;
|
||||
}).join("");
|
||||
if (typeof window.onBedarfChanged === "function") window.onBedarfChanged(data);
|
||||
const bits = [];
|
||||
if (etaText) {
|
||||
bits.push(`<span class="live-eta">${escapeHtml(etaText)}</span>`);
|
||||
}
|
||||
if (actionItems.length) {
|
||||
bits.push(`<button type="button" class="live-chip todo" data-live-open="jetzt">${icon("sparkles")}<span>Jetzt</span><span class="count todo">${actionItems.length}</span></button>`);
|
||||
}
|
||||
running.forEach((p) => {
|
||||
const pct = Math.max(0, Math.min(100, Math.round((Number(p.progress) || 0) * 100)));
|
||||
const finish = p.remain > 0 ? finishClock(p.remain) : "";
|
||||
const pausedFil = p.state === "paused" && (p.filament_empty || p.pause_reason);
|
||||
const label = [
|
||||
shortPrinter(p.name || p.host),
|
||||
pausedFil ? "Material!" : (pct ? pct + "%" : ""),
|
||||
finish,
|
||||
].filter(Boolean).join(" · ");
|
||||
const tip = [p.name || "", p.pause_reason || ""].filter(Boolean).join(" — ");
|
||||
bits.push(`<button type="button" class="live-chip run${pausedFil ? " warn" : ""}" data-live-open="run" style="--pct:${pct}" title="${escapeHtml(tip || p.name || "")}">
|
||||
<span class="live-chip-fill" aria-hidden="true"></span>
|
||||
<span class="live-chip-text">${escapeHtml(label)}</span>
|
||||
</button>`);
|
||||
});
|
||||
if (activeSends > 0) {
|
||||
bits.push(`<button type="button" class="live-chip" data-live-open="send">${icon("send")}<span>Senden</span><span class="count">${activeSends}</span></button>`);
|
||||
}
|
||||
if (alertCount > 0) {
|
||||
bits.push(`<button type="button" class="live-chip bad" data-live-open="alerts">${icon("bell")}<span>Meldungen</span><span class="count bad">${alertCount}</span></button>`);
|
||||
}
|
||||
setListHtml(box, bits.join(""));
|
||||
box.querySelectorAll("[data-live-open]").forEach((btn) => {
|
||||
btn.onclick = () => openLivePop(btn.dataset.liveOpen);
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshTasks() {
|
||||
let items;
|
||||
try { items = await api("/aufgaben"); } catch (error) { return; }
|
||||
items = items || [];
|
||||
document.getElementById("todo-box").hidden = !items.length;
|
||||
document.getElementById("todo-count").textContent = items.length;
|
||||
const list = document.getElementById("todo-list");
|
||||
list.innerHTML = items.map((p) => `<li class="queue-item">
|
||||
<div class="queue-row">
|
||||
<a class="queue-link grow" href="/#${encodeURIComponent(p.host)}"><span class="queue-name">${escapeHtml(p.name || p.host)}</span></a>
|
||||
<span class="queue-tools"><button type="button" class="icon-btn done-btn" data-cleared="${escapeHtml(p.host)}" title="Platte geräumt (zweimal klicken)" aria-label="Platte geräumt">${icon("check")}</button></span>
|
||||
</div>
|
||||
<span class="queue-msg">Platte räumen${p.state === "complete" ? " · Druck fertig" : ""}</span>
|
||||
${p.filename ? `<span class="queue-sub" title="${escapeHtml(p.filename)}">${escapeHtml(jobLabel(p.filename))}</span>` : ""}
|
||||
</li>`).join("");
|
||||
list.querySelectorAll("[data-cleared]").forEach((btn) => {
|
||||
function bindLivePanelActions(panel) {
|
||||
if (!panel) return;
|
||||
panel.querySelectorAll("[data-cleared]").forEach((btn) => {
|
||||
btn.onclick = async () => {
|
||||
const host = btn.dataset.cleared;
|
||||
const payload = await promptAbraeumen(btn.closest("li")?.querySelector(".queue-name")?.textContent || host);
|
||||
@@ -550,12 +410,146 @@ async function refreshTasks() {
|
||||
await api("/drucker/" + encodeURIComponent(host) + "/abraeumen", { method: "POST", json: payload });
|
||||
toast("Platte frei.");
|
||||
} catch (error) { toast(error.message, "bad"); }
|
||||
refreshTasks();
|
||||
refreshJetzt();
|
||||
if (typeof window.onQueueChanged === "function") window.onQueueChanged();
|
||||
btn.disabled = false;
|
||||
};
|
||||
});
|
||||
panel.querySelectorAll("[data-cancel-q]").forEach((btn) => {
|
||||
btn.onclick = () => cancelQueueItem({ id: Number(btn.dataset.cancelQ), status: "waiting" }, btn);
|
||||
});
|
||||
panel.querySelectorAll("[data-resume-q]").forEach((btn) => {
|
||||
btn.onclick = () => armButton(btn, "Fortsetzen?", async () => {
|
||||
try {
|
||||
await api("/druck-queue/" + btn.dataset.resumeQ, { method: "PATCH", json: { status: "running", resume: true } });
|
||||
toast("Ablauf geht weiter.");
|
||||
} catch (error) { toast(error.message, "bad"); }
|
||||
refreshJetzt();
|
||||
if (typeof window.onQueueChanged === "function") window.onQueueChanged();
|
||||
});
|
||||
});
|
||||
panel.querySelectorAll("[data-dismiss-q]").forEach((btn) => {
|
||||
btn.onclick = () => armButton(btn, "Ausblenden?", async () => {
|
||||
try { await api("/druck-queue/" + btn.dataset.dismissQ, { method: "PATCH", json: { status: "dismissed" } }); } catch (error) { toast(error.message, "bad"); }
|
||||
refreshJetzt();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshJetzt() {
|
||||
let data;
|
||||
try { data = await api("/jetzt"); } catch (error) { return; }
|
||||
// Wartende ohne Materialgrund nicht in Jetzt — nur echte Handlungen + Material-Wartegründe
|
||||
const actionItems = (data.actions || []).filter((a) => a.kind !== "waiting" || a.reason);
|
||||
const running = data.running || [];
|
||||
const eta = data.eta || {};
|
||||
const multiEta = running.filter((p) => Number(p.remain) > 0).length >= 2;
|
||||
const etaText = multiEta && eta.all_done_in_s > 0 ? "Alles fertig um " + finishClock(eta.all_done_in_s) : "";
|
||||
|
||||
const etaEl = document.getElementById("jetzt-eta");
|
||||
if (etaEl) {
|
||||
if (etaText) {
|
||||
etaEl.hidden = false;
|
||||
etaEl.textContent = etaText;
|
||||
} else {
|
||||
etaEl.hidden = true;
|
||||
etaEl.textContent = "";
|
||||
}
|
||||
}
|
||||
|
||||
const jetztBox = document.getElementById("jetzt-box");
|
||||
const jetztCount = document.getElementById("jetzt-count");
|
||||
if (jetztCount) {
|
||||
jetztCount.textContent = actionItems.length;
|
||||
jetztCount.className = "count" + (actionItems.length ? " todo" : "");
|
||||
}
|
||||
if (jetztBox) {
|
||||
jetztBox.classList.toggle("is-empty", !actionItems.length);
|
||||
setListHtml(document.getElementById("jetzt-list"), actionItems.map(actionItemHtml).join(""));
|
||||
}
|
||||
|
||||
const runBox = document.getElementById("run-box");
|
||||
if (runBox) {
|
||||
runBox.hidden = !running.length;
|
||||
setListHtml(
|
||||
document.getElementById("run-list"),
|
||||
running.map((p) => runLineHtml({
|
||||
name: p.name || p.host,
|
||||
host: p.host,
|
||||
progress: p.progress,
|
||||
remain: p.remain,
|
||||
filename: p.filename,
|
||||
cancelId: p.queue_id,
|
||||
})).join(""),
|
||||
);
|
||||
}
|
||||
|
||||
const sends = data.sends || [];
|
||||
const activeSends = sends.filter((j) => j.status === "pending" || j.status === "running");
|
||||
const sendBox = document.getElementById("send-box");
|
||||
if (sendBox) {
|
||||
sendBox.hidden = !activeSends.length && !sends.some((j) => j.status === "error");
|
||||
const sendCount = document.getElementById("send-count");
|
||||
if (sendCount) sendCount.textContent = activeSends.length;
|
||||
setListHtml(
|
||||
document.getElementById("send-list"),
|
||||
sends.map((job) => {
|
||||
const pct = Math.max(0, Math.min(100, Number(job.progress) || 0));
|
||||
const cls = job.status === "error" ? "bad" : "";
|
||||
return `<li class="queue-item ${job.status === "error" ? "error" : ""}">
|
||||
<span class="queue-name">${escapeHtml(job.printer || "")}</span>
|
||||
<span class="queue-msg">${escapeHtml(job.message || "")}</span>
|
||||
<span class="send-progress-track slim"><span class="send-progress-bar ${cls}" style="width:${pct}%"></span></span>
|
||||
</li>`;
|
||||
}).join(""),
|
||||
);
|
||||
}
|
||||
|
||||
const alerts = data.alerts || [];
|
||||
const alertCount = data.unread_alerts || alerts.length;
|
||||
const alertBox = document.getElementById("alert-box");
|
||||
if (alertBox) {
|
||||
alertBox.hidden = !alerts.length;
|
||||
const alertCountEl = document.getElementById("alert-count");
|
||||
if (alertCountEl) alertCountEl.textContent = alertCount;
|
||||
setListHtml(
|
||||
document.getElementById("alert-list"),
|
||||
alerts.map((item) => `<li class="queue-item">
|
||||
<div class="queue-row"><span class="grow"><span class="queue-name">${escapeHtml(item.printer || item.kind)}</span></span><span class="queue-sub">${escapeHtml(clockOf(item.created_at))}</span></div>
|
||||
<span class="queue-msg">${escapeHtml(item.message)}</span>
|
||||
</li>`).join(""),
|
||||
);
|
||||
}
|
||||
|
||||
renderLiveChips({
|
||||
actionItems,
|
||||
running,
|
||||
activeSends: activeSends.length,
|
||||
alertCount,
|
||||
etaText,
|
||||
});
|
||||
|
||||
const mobileBadge = document.getElementById("mobile-jetzt-badge");
|
||||
if (mobileBadge) {
|
||||
const n = actionItems.length || running.length || activeSends.length || alertCount;
|
||||
mobileBadge.hidden = !n;
|
||||
mobileBadge.textContent = String(actionItems.length || n);
|
||||
mobileBadge.classList.toggle("todo", !!actionItems.length);
|
||||
}
|
||||
|
||||
if (typeof window.onBedarfChanged === "function") {
|
||||
window.onBedarfChanged({ shortfall: Array(data.bedarf_shortfall || 0).fill({}) });
|
||||
}
|
||||
|
||||
bindLivePanelActions(document.getElementById("live-panel"));
|
||||
}
|
||||
|
||||
function refreshPrintQueue() { return refreshJetzt(); }
|
||||
function refreshSendQueue() { return refreshJetzt(); }
|
||||
function refreshEvents() { return refreshJetzt(); }
|
||||
function refreshTasks() { return refreshJetzt(); }
|
||||
function refreshBedarf() { return refreshJetzt(); }
|
||||
|
||||
(function mobileMoreSheet() {
|
||||
const sheet = document.getElementById("mobile-more-sheet");
|
||||
const btn = document.getElementById("mobile-more");
|
||||
@@ -563,7 +557,6 @@ async function refreshTasks() {
|
||||
btn.addEventListener("click", () => {
|
||||
if (typeof sheet.showModal === "function") sheet.showModal();
|
||||
});
|
||||
// Modal schließt und blockiert sonst die ganze App (Chrome).
|
||||
sheet.addEventListener("click", (event) => {
|
||||
const link = event.target.closest("a[href]");
|
||||
if (!link) return;
|
||||
@@ -581,36 +574,43 @@ if ("serviceWorker" in navigator) {
|
||||
navigator.serviceWorker.register("/static/sw.js", { updateViaCache: "none" }).catch(() => {});
|
||||
}
|
||||
|
||||
document.getElementById("events-clear").addEventListener("click", async (event) => {
|
||||
document.getElementById("events-clear")?.addEventListener("click", async (event) => {
|
||||
event.preventDefault();
|
||||
try { await api("/ereignisse/gelesen", { method: "POST", json: {} }); } catch (error) { /* still */ }
|
||||
refreshEvents();
|
||||
refreshJetzt();
|
||||
if (typeof window.onEventsRead === "function") window.onEventsRead();
|
||||
});
|
||||
|
||||
(function livePopClose() {
|
||||
const bar = document.getElementById("live-bar");
|
||||
const pop = document.getElementById("live-pop");
|
||||
if (!bar || !pop) return;
|
||||
document.addEventListener("click", (event) => {
|
||||
if (pop.hidden) return;
|
||||
if (bar.contains(event.target)) return;
|
||||
closeLivePop();
|
||||
});
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") closeLivePop();
|
||||
});
|
||||
})();
|
||||
|
||||
window.refreshPrintQueue = refreshPrintQueue;
|
||||
window.refreshSendQueue = refreshSendQueue;
|
||||
window.refreshBedarf = refreshBedarf;
|
||||
refreshSendQueue();
|
||||
refreshPrintQueue();
|
||||
refreshEvents();
|
||||
refreshTasks();
|
||||
refreshBedarf();
|
||||
window.refreshTasks = refreshTasks;
|
||||
window.refreshJetzt = refreshJetzt;
|
||||
refreshJetzt();
|
||||
|
||||
(function liveUpdates() {
|
||||
let streamLive = false;
|
||||
const sseKinds = {
|
||||
queue: () => {
|
||||
refreshPrintQueue();
|
||||
refreshBedarf();
|
||||
},
|
||||
senden: () => refreshSendQueue(),
|
||||
ereignis: () => refreshEvents(),
|
||||
aufgaben: () => refreshTasks(),
|
||||
queue: () => refreshJetzt(),
|
||||
senden: () => refreshJetzt(),
|
||||
ereignis: () => refreshJetzt(),
|
||||
aufgaben: () => refreshJetzt(),
|
||||
drucker: () => {
|
||||
refreshPrintQueue();
|
||||
refreshTasks();
|
||||
refreshJetzt();
|
||||
if (typeof window.onDruckerLive === "function") window.onDruckerLive();
|
||||
},
|
||||
};
|
||||
@@ -663,12 +663,7 @@ window.refreshTasks = refreshTasks;
|
||||
});
|
||||
}
|
||||
|
||||
// Poller nur als Fallback, wenn SSE tot ist — sonst staut Chrome die Verbindungen.
|
||||
createPoller(whenStreamDown(refreshPrintQueue), 8000).start();
|
||||
createPoller(whenStreamDown(refreshSendQueue), 4000).start();
|
||||
createPoller(whenStreamDown(refreshEvents), 12000).start();
|
||||
createPoller(whenStreamDown(refreshTasks), 12000).start();
|
||||
createPoller(whenStreamDown(refreshBedarf), 30000).start();
|
||||
createPoller(whenStreamDown(refreshJetzt), 8000).start();
|
||||
})();
|
||||
</script>
|
||||
{% block script %}{% endblock %}
|
||||
|
||||
Reference in new issue
Block a user