New web UI with iPhone web app
CI / test (push) Successful in 3m44s
CI / image (push) Successful in 44s

Redesigned status, log, tag generator and settings pages: spool rings in
filament colour, live job card (progress and time left read from CC2
Control's /api/printer while a job runs), log page with filters, iOS-style
settings with a write/dry-run switch. On phones: bottom tab bar, printer
switcher, slot details in a bottom sheet. Dark mode follows the system.
Web app manifest and home screen icons for "Add to Home Screen".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014GcEk5tkfFSYD6NTF66NkC
This commit is contained in:
Jonas MenzelandClaude Opus 5.5 committed 2026-10-25 19:55:07 +01:00
1 parent 67c3b54903
commit ae44441cfb
16 files changed
+982 -285

No files matched your search

+8 -1
View File
@@ -15,7 +15,7 @@ Bridge zwischen der Canvas eines oder mehrerer **Elegoo Centauri Carbon 2** (Com
| System | Zugriff | | System | Zugriff |
|---|---| |---|---|
| Drucker | **nur lesen**: `cat /opt/usr/canvas_record_info.json` per SSH. Kein MQTT, keine Druckerbefehle, keine Registrierung als MQTT-Client. | | Drucker | **nur lesen**: `cat /opt/usr/canvas_record_info.json` per SSH. Kein MQTT, keine Druckerbefehle, keine Registrierung als MQTT-Client. |
| CC2 Control | Spulenverwaltung: `GET /api/spools`, `POST /api/spools/save`, `/assign`, `/enable` (nur nach Bestätigung) | | CC2 Control | Spulenverwaltung: `GET /api/spools`, `POST /api/spools/save`, `/assign`, `/enable` (nur nach Bestätigung); während eines Drucks zusätzlich `GET /api/printer` (nur für Fortschritt und Restzeit in der Oberfläche) |
| Spoolman | `GET /spool`, `PATCH /spool/{id}` (`location`), `PUT /spool/{id}/use` | | Spoolman | `GET /spool`, `PATCH /spool/{id}` (`location`), `PUT /spool/{id}/use` |
**Dry-Run ist Standard.** Solange er aktiv ist, werden alle Schreibaktionen nur als „würde …“ protokolliert. **Dry-Run ist Standard.** Solange er aktiv ist, werden alle Schreibaktionen nur als „würde …“ protokolliert.
@@ -70,6 +70,13 @@ Ein Update von v1 übernimmt Einstellungen, Zuordnungen, Buchungsstände und den
Drucker heißt danach „CC2“ (Ort bleibt „CC2 Slot N“) und kann in den Einstellungen umbenannt werden – Ort und Drucker heißt danach „CC2“ (Ort bleibt „CC2 Slot N“) und kann in den Einstellungen umbenannt werden – Ort und
Felder in Spoolman ziehen dann mit. Felder in Spoolman ziehen dann mit.
### Am iPhone als Web-App
`http://10.10.6.10:5066` in Safari öffnen → *Teilen* → **Zum Home-Bildschirm**. Die Bridge startet dann ohne
Safari-Leiste mit eigenem Icon. Unten liegen die Tabs Status, Protokoll, Tags und Einstellungen; auf *Status*
wechselt man oben zwischen den Druckern, ein Tipp auf einen Slot zeigt Details und die Aktionen. Hell/Dunkel folgt
der Systemeinstellung. Offline-Modus und Push-Mitteilungen gibt es nicht (dafür bräuchte es HTTPS).
### Umgebungsvariablen ### Umgebungsvariablen
Siehe [`.env.example`](.env.example). `PRINTER_HOST`, `SSH_PORT`, `SSH_USER`, `CC2_URL`, `SPOOLMAN_URL`, Siehe [`.env.example`](.env.example). `PRINTER_HOST`, `SSH_PORT`, `SSH_USER`, `CC2_URL`, `SPOOLMAN_URL`,
+29
View File
@@ -198,6 +198,7 @@ class PrinterWorker:
self.tray_status: dict[int, int] = {} self.tray_status: dict[int, int] = {}
self.record: dict | None = None self.record: dict | None = None
self.cc2_data: dict | None = None self.cc2_data: dict | None = None
self.print_info: dict | None = None # CC2 Control /api/printer, only read while a job runs
@property @property
def id(self) -> str: def id(self) -> str:
@@ -250,6 +251,12 @@ class PrinterWorker:
self._error("cc2", None) self._error("cc2", None)
except CC2Error as e: except CC2Error as e:
self._error("cc2", str(e)) self._error("cc2", str(e))
self.print_info = None
if self.cc2_data is not None and (self.cc2_data.get("job") or {}).get("active"):
try:
self.print_info = self.cc2.printer()
except CC2Error:
pass # progress is only shown in the UI; the spool list above is what counts
if self.record is not None: if self.record is not None:
self._process_record(self.record) self._process_record(self.record)
self._process_scans() self._process_scans()
@@ -693,9 +700,31 @@ class PrinterWorker:
"cc2_enabled": None if cc2 is None else bool(cc2.get("enabled")), "cc2_enabled": None if cc2 is None else bool(cc2.get("enabled")),
"job_active": None if cc2 is None else bool((cc2.get("job") or {}).get("active")), "job_active": None if cc2 is None else bool((cc2.get("job") or {}).get("active")),
"host_key": self.bridge.keys.host_key(self.id) is not None, "host_key": self.bridge.keys.host_key(self.id) is not None,
"job": self._job_summary(),
"slots": slots, "slots": slots,
} }
def _job_summary(self) -> dict | None:
job = (self.cc2_data or {}).get("job") or {}
if not job.get("active"):
return None
used = [s for s in job.get("slots") or [] if isinstance(s, dict)]
info = self.print_info or {}
pr, machine = info.get("print") or {}, info.get("machine") or {}
progress = machine.get("progress")
return {
"file": str(job.get("file") or pr.get("filename") or "").rsplit("/", 1)[-1],
"started": job.get("started"),
"grams": round(sum(_num(s.get("grams")) for s in used), 2),
"slots": [{"slot": i, "grams": round(_num(s.get("grams")), 2)} for i, s in enumerate(used)
if _num(s.get("grams")) > 0],
"progress": progress if isinstance(progress, (int, float)) else None,
"layer": pr.get("current_layer"),
"layers": pr.get("total_layers"),
"remaining": pr.get("remaining"),
"state": pr.get("state"),
}
class Bridge: class Bridge:
def __init__(self, data_dir: Path, settings: SettingsStore, keys: KeyStore, *, def __init__(self, data_dir: Path, settings: SettingsStore, keys: KeyStore, *,
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#14181F"/><circle cx="32" cy="32" r="23" fill="none" stroke="#fff" stroke-width="4.5"/><circle cx="32" cy="32" r="12" fill="none" stroke="#6E8BFF" stroke-width="9"/><circle cx="32" cy="32" r="3" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 318 B

+223 -71
View File
@@ -2,98 +2,223 @@
<html lang="de"> <html lang="de">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>{% block title %}CC2 Bridge{% endblock %}</title> <title>{% block title %}CC2 Bridge{% endblock %}</title>
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-title" content="CC2 Bridge">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="theme-color" content="#EDEFF2" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#0E1116" media="(prefers-color-scheme: dark)">
<meta name="format-detection" content="telephone=no">
<link rel="manifest" href="/manifest.webmanifest">
<link rel="icon" href="/static/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/static/icon-180.png">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style> <style>
:root { :root {
--bg: #f6f7f9; --panel: #ffffff; --text: #1d2330; --muted: #5d6677; --line: #dde1e8; --bg: #EDEFF2; --surface: #FFFFFF; --surface2: #F6F7F9; --ink: #14181F; --muted: #5B6472; --faint: #9AA3B0;
--accent: #2856d9; --accent-text: #fff; --ok: #1b7f3b; --warn: #a15c00; --err: #b3261e; --line: #DCE0E6; --line2: #EDEFF2; --track: #E4E7EC;
--ok-bg: #e7f5ec; --warn-bg: #fff4e0; --err-bg: #fde8e7; --dry-bg: #eef1ff; --code: #f0f2f5; --accent: #2F5BEA; --accent-ink: #FFFFFF; --accent-bg: #E6ECFD;
--radius: 8px; --ok: #17803D; --ok-bg: #E3F4E9; --warn: #9A5B00; --warn-bg: #FFF1DC; --err: #B42318; --err-bg: #FDE7E5; --err-line: #F3C6C1;
--hero: #14181F; --hero-ink: #FFFFFF; --hero-muted: #A9B3C2; --hero-track: #2B323D; --hero-accent: #6E8BFF; --hero-line: #3A4250;
--tabbar: rgba(255, 255, 255, .92);
--radius: 16px; --radius-sm: 10px;
--font: Manrope, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
color-scheme: light;
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root { :root {
--bg: #14171c; --panel: #1c2027; --text: #e6e9ef; --muted: #9aa3b2; --line: #2e343e; --bg: #0E1116; --surface: #171B22; --surface2: #1E232C; --ink: #E8ECF2; --muted: #9AA4B2; --faint: #6B7482;
--accent: #6d8dff; --accent-text: #0d1020; --ok: #5cc982; --warn: #f0b55c; --err: #ff8a80; --line: #2A303A; --line2: #222731; --track: #2A303A;
--ok-bg: #16301f; --warn-bg: #33270f; --err-bg: #3a1a18; --dry-bg: #1e2340; --code: #232832; --accent: #7C97FF; --accent-ink: #0B1230; --accent-bg: #1D2747;
--ok: #5CC98A; --ok-bg: #13291C; --warn: #F0B55C; --warn-bg: #33270F; --err: #FF8A80; --err-bg: #3A1A18; --err-line: #5A2622;
--hero: #1F2531; --hero-track: #333B48; --hero-line: #3F4857;
--tabbar: rgba(23, 27, 34, .92);
color-scheme: dark;
} }
} }
* { box-sizing: border-box; } * { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--text); html { -webkit-text-size-adjust: 100%; }
font: 15px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; } body { margin: 0; background: var(--bg); color: var(--ink); font: 15px/1.45 var(--font); -webkit-font-smoothing: antialiased;
header { background: var(--panel); border-bottom: 1px solid var(--line); } -webkit-tap-highlight-color: transparent; }
.wrap { max-width: 1180px; margin: 0 auto; padding: 0 16px; } a { color: var(--accent); }
header .wrap { display: flex; align-items: center; gap: 24px; min-height: 52px; flex-wrap: wrap; }
header h1 { font-size: 16px; margin: 0; font-weight: 650; } /* top bar (wide screens) */
nav { display: flex; gap: 4px; flex-wrap: wrap; } .top { background: var(--surface); border-bottom: 1px solid var(--line); padding-top: env(safe-area-inset-top); }
nav a { color: var(--muted); text-decoration: none; padding: 6px 10px; border-radius: 6px; } .top .in { max-width: 1240px; margin: 0 auto; padding: 0 24px; min-height: 64px; display: flex; align-items: center; gap: 28px; flex-wrap: wrap; }
nav a.on, nav a:hover { color: var(--text); background: var(--code); } .brand { display: flex; align-items: center; gap: 10px; font-weight: 800; font-size: 17px; letter-spacing: -.01em; color: var(--ink); text-decoration: none; }
.mode { margin-left: auto; font-size: 13px; padding: 3px 10px; border-radius: 99px; font-weight: 600; } .brand img { width: 30px; height: 30px; border-radius: 8px; }
.mode.dry { background: var(--dry-bg); color: var(--accent); } .top nav { display: flex; gap: 4px; flex-wrap: wrap; }
.top nav a { text-decoration: none; color: var(--muted); padding: 8px 14px; border-radius: var(--radius-sm); font-weight: 600; }
.top nav a:hover { color: var(--ink); }
.top nav a.on { color: var(--ink); background: var(--bg); font-weight: 700; }
.top .right { margin-left: auto; display: flex; align-items: center; gap: 14px; }
.poll { font-size: 13px; color: var(--muted); }
/* mode pill */
.mode { display: inline-flex; align-items: center; gap: 7px; font-weight: 700; font-size: 13px; padding: 6px 12px; border-radius: 99px;
text-decoration: none; white-space: nowrap; }
.mode::before { content: ""; width: 8px; height: 8px; border-radius: 99px; background: currentColor; }
.mode.dry { background: var(--accent-bg); color: var(--accent); }
.mode.live { background: var(--err-bg); color: var(--err); } .mode.live { background: var(--err-bg); color: var(--err); }
main { padding: 20px 0 48px; } .mode:empty { display: none; }
h2 { font-size: 18px; margin: 28px 0 12px; }
h2:first-child { margin-top: 0; } main { max-width: 1240px; margin: 0 auto; padding: 28px 24px 56px; }
h3 { font-size: 15px; margin: 0 0 8px; } .pagehead { display: none; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
.panel { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 16px; } .pagehead h1 { margin: 0; font-size: 32px; font-weight: 800; letter-spacing: -.02em; line-height: 1.15; }
.grid { display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); } h2 { font-size: 20px; font-weight: 800; letter-spacing: -.01em; margin: 0; }
h3 { font-size: 15px; font-weight: 800; margin: 0; }
/* tab bar (phones) */
.tabbar { display: none; position: fixed; left: 0; right: 0; bottom: 0; z-index: 20; background: var(--tabbar);
-webkit-backdrop-filter: saturate(180%) blur(20px); backdrop-filter: saturate(180%) blur(20px);
border-top: 1px solid var(--line); padding: 6px 8px calc(6px + env(safe-area-inset-bottom));
grid-template-columns: repeat(4, minmax(0, 1fr)); }
.tabbar a { text-decoration: none; color: var(--muted); display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 3px; min-height: 48px; font-size: 11px; font-weight: 700; }
.tabbar a.on { color: var(--accent); }
.tabbar svg { width: 24px; height: 24px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
@media (max-width: 759px) {
.top { display: none; }
.tabbar { display: grid; }
main { padding: calc(14px + env(safe-area-inset-top)) max(16px, env(safe-area-inset-right)) calc(96px + env(safe-area-inset-bottom)) max(16px, env(safe-area-inset-left)); }
.pagehead { display: flex; }
.desktop-only { display: none !important; }
}
@media (min-width: 760px) { .phone-only { display: none !important; } }
/* building blocks */
.card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); padding: 16px; }
@media (max-width: 759px) { .card { border: 0; } }
.stack { display: flex; flex-direction: column; gap: 14px; }
.row { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
.muted { color: var(--muted); } .muted { color: var(--muted); }
.small { font-size: 13px; } .small { font-size: 13px; }
.banner { border-radius: var(--radius); padding: 10px 14px; margin-bottom: 12px; display: flex; gap: 12px; .mono { font-family: var(--mono); }
align-items: center; flex-wrap: wrap; } .label { font-size: 12px; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); }
.section-label { margin: 22px 4px 8px; }
.chip { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 700; padding: 3px 9px; border-radius: 99px;
background: var(--bg); color: var(--muted); white-space: nowrap; }
.chip.dot::before { content: ""; width: 7px; height: 7px; border-radius: 99px; background: currentColor; }
.chip.ok { background: var(--ok-bg); color: var(--ok); }
.chip.warn { background: var(--warn-bg); color: var(--warn); }
.chip.err { background: var(--err-bg); color: var(--err); }
.chip.accent { background: var(--accent-bg); color: var(--accent); }
.banner { border-radius: 14px; padding: 12px 14px; display: flex; gap: 12px; align-items: center; flex-wrap: wrap; font-size: 14px; font-weight: 600; }
.banner.err { background: var(--err-bg); color: var(--err); } .banner.err { background: var(--err-bg); color: var(--err); }
.banner.warn { background: var(--warn-bg); color: var(--warn); } .banner.warn { background: var(--warn-bg); color: var(--warn); }
.banner.ok { background: var(--ok-bg); color: var(--ok); } .banner.ok { background: var(--ok-bg); color: var(--ok); }
.banner.dry { background: var(--dry-bg); color: var(--accent); } .banner.dry { background: var(--accent-bg); color: var(--accent); }
button, .btn { font: inherit; border: 1px solid var(--line); background: var(--panel); color: var(--text); .banner a { color: inherit; }
padding: 6px 12px; border-radius: 6px; cursor: pointer; }
button.primary { background: var(--accent); color: var(--accent-text); border-color: var(--accent); } button, .btn { font: inherit; font-size: 14px; font-weight: 700; min-height: 40px; padding: 0 14px; border-radius: var(--radius-sm);
button.danger { color: var(--err); } border: 1px solid var(--line); background: var(--surface); color: var(--ink); cursor: pointer; display: inline-flex; align-items: center;
justify-content: center; gap: 8px; text-decoration: none; }
button:hover, .btn:hover { border-color: var(--faint); }
button.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); }
button.danger { color: var(--err); border-color: var(--err-line); }
button.plain { border: 0; background: transparent; color: var(--accent); padding: 0; min-height: 44px; }
button:disabled { opacity: .5; cursor: default; } button:disabled { opacity: .5; cursor: default; }
input, select, textarea { font: inherit; color: var(--text); background: var(--bg); border: 1px solid var(--line); button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible, summary:focus-visible {
border-radius: 6px; padding: 6px 8px; width: 100%; } outline: 2px solid var(--accent); outline-offset: 2px; }
textarea { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px; }
label { display: block; font-size: 13px; color: var(--muted); margin-bottom: 4px; } input, select, textarea { font: inherit; font-size: 16px; color: var(--ink); background: var(--surface2); border: 1px solid var(--line);
.field { margin-bottom: 12px; } border-radius: var(--radius-sm); padding: 0 12px; min-height: 44px; width: 100%; }
.row { display: flex; gap: 12px; flex-wrap: wrap; align-items: flex-end; } textarea { padding: 10px 12px; font-family: var(--mono); font-size: 13px; }
.row > .field { flex: 1 1 160px; } input[type=color] { padding: 4px; width: 52px; flex: 0 0 52px; }
code, .mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px; } .field { display: flex; flex-direction: column; gap: 5px; }
code { background: var(--code); padding: 1px 5px; border-radius: 4px; overflow-wrap: anywhere; } .field > label, .flabel { font-size: 13px; color: var(--muted); font-weight: 600; }
pre { background: var(--code); padding: 10px; border-radius: 6px; overflow-x: auto; white-space: pre-wrap; .fields { display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); }
word-break: break-all; margin: 0; }
table { width: 100%; border-collapse: collapse; } /* iOS-style switch */
th, td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--line); vertical-align: top; } .switch { position: relative; display: inline-flex; flex: 0 0 auto; }
th { font-size: 13px; color: var(--muted); font-weight: 600; } .switch input { position: absolute; inset: 0; opacity: 0; margin: 0; min-height: 0; cursor: pointer; }
.switch span { width: 52px; height: 32px; border-radius: 99px; background: var(--track); transition: background .2s; position: relative; }
.switch span::after { content: ""; position: absolute; top: 3px; left: 3px; width: 26px; height: 26px; border-radius: 99px; background: #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, .25); transition: transform .2s; }
.switch input:checked + span { background: var(--ok); }
.switch.danger input:checked + span { background: var(--err); }
.switch input:checked + span::after { transform: translateX(20px); }
.switch input:focus-visible + span { outline: 2px solid var(--accent); outline-offset: 2px; }
/* grouped lists */
.group { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; }
@media (max-width: 759px) { .group { border: 0; } }
.item { display: flex; align-items: center; gap: 12px; padding: 12px 14px; min-height: 56px; border-top: 1px solid var(--line2); }
.item:first-child { border-top: 0; }
.item .grow { flex: 1; min-width: 0; }
.item .title { font-weight: 800; }
.item .sub { font-size: 13px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.chev { width: 8px; height: 14px; flex: 0 0 8px; fill: none; stroke: var(--faint); stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
button.item { width: 100%; text-align: left; border: 0; border-top: 1px solid var(--line2); border-radius: 0; background: transparent;
font-weight: inherit; font-size: inherit; color: inherit; justify-content: flex-start; }
button.item:first-child { border-top: 0; }
button.item:hover { background: var(--surface2); }
.swatch { display: inline-block; width: 14px; height: 14px; border-radius: 99px; border: 1px solid var(--line); vertical-align: -2px; }
.ring { border-radius: 99px; display: flex; align-items: center; justify-content: center; flex: 0 0 auto; box-shadow: inset 0 0 0 1px var(--line); }
.ring > div { border-radius: 99px; background: var(--surface); display: flex; flex-direction: column; align-items: center; justify-content: center; }
.icon-badge { width: 32px; height: 32px; flex: 0 0 32px; border-radius: 10px; display: flex; align-items: center; justify-content: center;
font-weight: 800; font-size: 14px; background: var(--bg); color: var(--ink); }
.icon-badge svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round; }
code { font-family: var(--mono); font-size: 13px; background: var(--surface2); padding: 1px 6px; border-radius: 6px; overflow-wrap: anywhere; }
pre { font-family: var(--mono); font-size: 12px; background: var(--surface2); padding: 12px; border-radius: var(--radius-sm); overflow-x: auto;
white-space: pre-wrap; word-break: break-all; margin: 0; }
table { width: 100%; border-collapse: collapse; font-size: 14px; }
th, td { text-align: left; padding: 8px; border-bottom: 1px solid var(--line2); vertical-align: top; }
th { font-size: 12px; color: var(--muted); font-weight: 700; }
.table-wrap { overflow-x: auto; } .table-wrap { overflow-x: auto; }
.swatch { display: inline-block; width: 14px; height: 14px; border-radius: 3px; border: 1px solid var(--line); dl.kv { display: grid; grid-template-columns: max-content 1fr; gap: 6px 14px; margin: 0; font-size: 14px; }
vertical-align: -2px; }
.tag { display: inline-block; font-size: 12px; padding: 1px 7px; border-radius: 99px; background: var(--code); }
.tag.ok { background: var(--ok-bg); color: var(--ok); }
.tag.warn { background: var(--warn-bg); color: var(--warn); }
.tag.err { background: var(--err-bg); color: var(--err); }
.tag.dry { background: var(--dry-bg); color: var(--accent); }
dl.kv { display: grid; grid-template-columns: max-content 1fr; gap: 3px 12px; margin: 0; font-size: 14px; }
dl.kv dt { color: var(--muted); } dl.kv dt { color: var(--muted); }
dl.kv dd { margin: 0; overflow-wrap: anywhere; } dl.kv dd { margin: 0; overflow-wrap: anywhere; }
.toast { position: fixed; bottom: 16px; left: 50%; transform: translateX(-50%); background: var(--text);
color: var(--bg); padding: 8px 14px; border-radius: 6px; opacity: 0; transition: opacity .2s; pointer-events: none; } dialog.sheet { border: 0; padding: 0; background: var(--surface); color: var(--ink); border-radius: 20px; width: min(480px, calc(100% - 32px));
max-height: calc(100% - 48px); box-shadow: 0 20px 60px rgba(0, 0, 0, .3); }
dialog.sheet::backdrop { background: rgba(10, 12, 16, .45); }
dialog.sheet .sheet-in { padding: 18px 18px 20px; display: flex; flex-direction: column; gap: 16px; }
@media (max-width: 759px) {
dialog.sheet { width: 100%; max-width: 100%; margin: auto 0 0; border-radius: 22px 22px 0 0; max-height: 88vh; }
dialog.sheet .sheet-in { padding-bottom: calc(20px + env(safe-area-inset-bottom)); }
dialog.sheet .grip { display: block; }
}
.grip { display: none; width: 40px; height: 5px; border-radius: 99px; background: var(--line); margin: 0 auto -6px; }
.toast { position: fixed; left: 50%; bottom: calc(24px + env(safe-area-inset-bottom)); transform: translateX(-50%); background: var(--ink);
color: var(--bg); padding: 10px 16px; border-radius: 12px; font-weight: 700; font-size: 14px; opacity: 0; transition: opacity .2s;
pointer-events: none; z-index: 50; max-width: calc(100% - 32px); }
@media (max-width: 759px) { .toast { bottom: calc(88px + env(safe-area-inset-bottom)); } }
.toast.show { opacity: 1; } .toast.show { opacity: 1; }
</style> </style>
{% block head %}{% endblock %}
</head> </head>
<body> <body>
<header><div class="wrap"> {% set tabs = [
<h1>CC2 ↔ Spoolman</h1> ('/', 'status.html', 'Status', 'M12 3a9 9 0 1 0 0 18a9 9 0 1 0 0-18M12 8a4 4 0 1 0 0 8a4 4 0 1 0 0-8'),
<nav> ('/log', 'log.html', 'Protokoll', 'M8 6h12M8 12h12M8 18h12M4 6h.01M4 12h.01M4 18h.01'),
<a href="/" class="{{ 'on' if active == 'status.html' }}">Status</a> ('/tags', 'tags.html', 'Tags', 'M6 8.5a8 8 0 0 1 0 7M10 6a12 12 0 0 1 0 12M14 3.5a16 16 0 0 1 0 17'),
<a href="/tags" class="{{ 'on' if active == 'tags.html' }}">Tag-Generator</a> ('/settings', 'settings.html', 'Einstellungen', 'M4 7h10M18 7h2M4 17h4M12 17h8M16 5v4M10 15v4'),
<a href="/settings" class="{{ 'on' if active == 'settings.html' }}">Einstellungen</a> ] %}
<header class="top"><div class="in">
<a class="brand" href="/"><img src="/static/icon.svg" alt="">CC2 Bridge</a>
<nav aria-label="Hauptnavigation">
{% for href, name, label, icon in tabs %}<a href="{{ href }}" class="{{ 'on' if active == name }}" {% if active == name %}aria-current="page"{% endif %}>{{ 'Tag-Generator' if name == 'tags.html' else label }}</a>{% endfor %}
</nav> </nav>
<span id="mode" class="mode"></span> <div class="right"><span class="poll" id="lastpoll"></span><a href="/settings" class="mode" id="mode"></a></div>
</div></header> </div></header>
<main><div class="wrap"> <main>
{% block content %}{% endblock %} <div class="pagehead"><h1>{% block heading %}CC2 Bridge{% endblock %}</h1><a href="/settings" class="mode" id="mode2"></a></div>
</div></main> {% block content %}{% endblock %}
<div id="toast" class="toast"></div> </main>
<nav class="tabbar" aria-label="Hauptnavigation">
{% for href, name, label, icon in tabs %}<a href="{{ href }}" class="{{ 'on' if active == name }}" {% if active == name %}aria-current="page"{% endif %}><svg viewBox="0 0 24 24" aria-hidden="true"><path d="{{ icon }}"/></svg>{{ label }}</a>{% endfor %}
</nav>
<div id="toast" class="toast" role="status" aria-live="polite"></div>
<script> <script>
const $ = (id) => document.getElementById(id); const $ = (id) => document.getElementById(id);
const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
@@ -119,11 +244,38 @@ async function copyText(text) {
toast('Kopiert'); toast('Kopiert');
} }
function fmtTime(t) { return t ? new Date(t * 1000).toLocaleString('de-DE') : '–'; } function fmtTime(t) { return t ? new Date(t * 1000).toLocaleString('de-DE') : '–'; }
function setMode(dry) { function fmtClock(t, sec) {
const m = $('mode'); return t ? new Date(t * 1000).toLocaleTimeString('de-DE', sec ? {} : { hour: '2-digit', minute: '2-digit' }) : '–';
m.textContent = dry ? 'Dry-Run' : 'Schreiben aktiv';
m.className = 'mode ' + (dry ? 'dry' : 'live');
} }
function fmtNum(v, d = 0) { return Number(v).toLocaleString('de-DE', { minimumFractionDigits: d, maximumFractionDigits: d }); }
function setMode(dry) {
for (const id of ['mode', 'mode2']) {
const m = $(id);
m.textContent = dry ? 'Dry-Run' : 'Schreiben aktiv';
m.className = 'mode ' + (dry ? 'dry' : 'live');
}
}
function setPoll(t) { $('lastpoll').textContent = t ? 'Abgefragt ' + fmtClock(t, true) : ''; }
function logIcon(e) {
if (e.level === 'error') return ['!', 'var(--err-bg)', 'var(--err)'];
if (e.level === 'warn') return ['!', 'var(--warn-bg)', 'var(--warn)'];
if (e.level === 'dry') return ['D', 'var(--accent-bg)', 'var(--accent)'];
if (/gebucht/.test(e.msg)) return ['g', 'var(--ok-bg)', 'var(--ok)'];
if (/fragt nach/.test(e.msg)) return ['↻', 'var(--warn-bg)', 'var(--warn)'];
if (/zugeordnet/.test(e.msg)) return ['+', 'var(--accent-bg)', 'var(--accent)'];
return ['✓', 'var(--bg)', 'var(--ink)'];
}
function splitPrinter(msg, names) {
// "erledigt: CC2 Links: in CC2 Control …" -> ["CC2 Links", "In CC2 Control …"]; the check mark already says "erledigt".
let pre = '';
if (msg.startsWith('erledigt: ')) msg = msg.slice(10);
else if (msg.startsWith('würde ')) { pre = 'würde '; msg = msg.slice(6); }
let printer = 'Bridge';
for (const n of names) if (msg.startsWith(n + ': ')) { printer = n; msg = msg.slice(n.length + 2); break; }
msg = pre + msg;
return [printer, msg.charAt(0).toUpperCase() + msg.slice(1)];
}
const CHEV = '<svg class="chev" viewBox="0 0 8 14" aria-hidden="true"><path d="M1 1l6 6-6 6"/></svg>';
api('GET', '/api/settings').then((s) => setMode(s.dry_run)).catch(() => {}); api('GET', '/api/settings').then((s) => setMode(s.dry_run)).catch(() => {});
</script> </script>
{% block script %}{% endblock %} {% block script %}{% endblock %}
+78
View File
@@ -0,0 +1,78 @@
{% extends "base.html" %}
{% block title %}CC2 Bridge – Protokoll{% endblock %}
{% block heading %}Protokoll{% endblock %}
{% block head %}
<style>
.wrap { max-width: 860px; }
.chips { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 6px; }
.chips button { border-radius: 99px; min-height: 36px; font-size: 13px; }
.chips button[aria-pressed=true] { background: var(--ink); border-color: var(--ink); color: var(--bg); }
.day { margin: 18px 4px 8px; }
.entries { padding: 0 14px; }
.entry { display: flex; gap: 12px; padding: 12px 0; border-top: 1px solid var(--line2); }
.entry:first-child { border-top: 0; }
.entry .msg { font-size: 14px; line-height: 1.4; overflow-wrap: anywhere; }
.entry .meta { font-size: 12px; color: var(--muted); margin-top: 2px; }
</style>
{% endblock %}
{% block content %}
<div class="wrap">
<div class="chips" id="chips"></div>
<div id="list"><div class="card muted">Lade …</div></div>
</div>
{% endblock %}
{% block script %}
<script>
const FILTERS = [
['all', 'Alle', () => true],
['book', 'Buchungen', (e) => /gebucht/.test(e.msg) && e.level !== 'dry'],
['warn', 'Hinweise', (e) => e.level === 'warn' || e.level === 'dry'],
['error', 'Fehler', (e) => e.level === 'error'],
];
let FILTER = 'all', DATA = null;
function renderChips() {
$('chips').innerHTML = FILTERS.map(([k, label]) =>
`<button aria-pressed="${k === FILTER}" onclick="setFilter('${k}')">${label}</button>`).join('');
}
function setFilter(k) { FILTER = k; renderChips(); render(); }
function dayLabel(t) {
const d = new Date(t * 1000), today = new Date();
const start = (x) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
const diff = Math.round((start(today) - start(d)) / 86400000);
if (diff === 0) return 'Heute';
if (diff === 1) return 'Gestern';
return d.toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long' });
}
function render() {
if (!DATA) return;
const names = (DATA.printers || []).map((p) => p.name);
const fn = FILTERS.find((f) => f[0] === FILTER)[2];
const groups = [];
for (const e of (DATA.log || []).filter(fn)) {
const day = dayLabel(e.time);
if (!groups.length || groups[groups.length - 1].day !== day) groups.push({ day, items: [] });
groups[groups.length - 1].items.push(e);
}
$('list').innerHTML = groups.map((g) => `<div class="label day">${esc(g.day)}</div>
<div class="group entries">${g.items.map((e) => {
const [ic, bg, fg] = logIcon(e), [pn, msg] = splitPrinter(e.msg, names);
return `<div class="entry"><div class="icon-badge" style="background:${bg};color:${fg}">${ic}</div>
<div style="flex:1;min-width:0"><div class="msg">${esc(msg)}</div>
<div class="meta">${esc(pn)} · <span class="mono">${fmtClock(e.time, true)}</span>${e.level === 'dry' ? ' · Dry-Run' : ''}</div></div></div>`;
}).join('')}</div>`).join('') || '<div class="card muted">Keine Einträge</div>';
}
async function load() {
try { DATA = await api('GET', '/api/status'); } catch (e) { $('list').innerHTML = `<div class="banner err">${esc(e.message)}</div>`; return; }
setMode(DATA.dry_run);
setPoll(DATA.last_poll);
render();
}
renderChips();
load();
setInterval(() => { if (!document.hidden) load(); }, 5000);
</script>
{% endblock %}
+135 -75
View File
@@ -1,133 +1,193 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}CC2 Bridge – Einstellungen{% endblock %} {% block title %}CC2 Bridge – Einstellungen{% endblock %}
{% block heading %}Einstellungen{% endblock %}
{% block head %}
<style>
.wrap { max-width: 860px; }
.wrap > .label:first-child { margin-top: 0; }
.modecard { display: flex; align-items: center; gap: 14px; }
details.pr { border-top: 1px solid var(--line2); }
details.pr:first-child { border-top: 0; }
details.pr > summary { list-style: none; cursor: pointer; }
details.pr > summary::-webkit-details-marker { display: none; }
details.pr > summary .item { border: 0; }
details.pr[open] > summary .chev { transform: rotate(90deg); }
.chev { transition: transform .15s; }
.prbody { padding: 4px 14px 16px; display: flex; flex-direction: column; gap: 14px; }
.printer-icon { width: 36px; height: 36px; flex: 0 0 36px; border-radius: 10px; background: var(--bg); display: flex; align-items: center; justify-content: center; }
.printer-icon svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.frow { display: flex; align-items: center; gap: 12px; padding: 8px 14px; min-height: 56px; border-top: 1px solid var(--line2); }
.frow:first-child { border-top: 0; }
.frow > label { flex: 1 1 200px; font-weight: 600; }
.frow > label small { display: block; font-weight: 500; color: var(--muted); font-size: 12px; }
.frow > input { flex: 0 1 260px; text-align: right; background: transparent; border-color: transparent; }
.frow > input:focus { background: var(--surface2); border-color: var(--line); text-align: left; }
@media (max-width: 759px) { .frow > input { flex-basis: 150px; } }
.actions { display: flex; gap: 8px; flex-wrap: wrap; }
.savebar { display: flex; align-items: center; gap: 12px; margin-top: 12px; }
</style>
{% endblock %}
{% block content %} {% block content %}
<h2>Drucker</h2> <div class="wrap">
<div id="printers"></div> <div class="card modecard">
<div class="row" style="margin-bottom:8px"> <div style="flex:1">
<button onclick="addPrinter()">Drucker hinzufügen</button> <div style="font-weight:800" id="modetitle">Schreiben aktiv</div>
<button class="primary" onclick="saveSettings()">Speichern</button> <div class="small muted">Aus = Dry-Run: Schreibaktionen in CC2 Control und Spoolman werden nur protokolliert.</div>
<span id="saved1" class="small muted"></span> </div>
</div> <label class="switch danger"><input type="checkbox" id="write" aria-label="Schreiben aktiv"><span></span></label>
<p class="small muted">Neue oder geänderte Drucker erst speichern, dann „Verbindung testen“.</p> </div>
<h2>Allgemein</h2> <div class="label section-label">Drucker</div>
<form id="settings" class="panel" onsubmit="event.preventDefault(); saveSettings()"> <div class="group">
<div class="row"> <div id="printers"></div>
<div class="field"><label for="spoolman_url">Spoolman-URL</label><input id="spoolman_url" required></div> <button class="item" style="color:var(--accent);font-weight:700;min-height:48px" onclick="addPrinter()">Drucker hinzufügen</button>
<div class="field"><label for="id_offset">ID-Offset</label><input id="id_offset" type="number" min="0" max="65000"></div>
</div> </div>
<div class="row"> <p class="small muted" style="margin:8px 4px 0">Neue oder geänderte Drucker erst speichern, dann „Verbindung testen“.</p>
<div class="field"><label for="poll_interval">Abfrage-Intervall (s)</label><input id="poll_interval" type="number" step="0.5" min="1" max="300"></div>
<div class="field"><label for="empty_grace">Slot leer nach (s)</label><input id="empty_grace" type="number" min="0"></div>
<div class="field"><label for="settle_seconds">Buchen nach Ruhe (s)</label><input id="settle_seconds" type="number" min="0"></div>
</div>
<div class="row">
<div class="field"><label for="location_slot">Ort im Slot (<code>{printer}</code> = Druckername, <code>{n}</code> = 1–4)</label><input id="location_slot" required></div>
<div class="field"><label for="location_storage">Ort im Lager</label><input id="location_storage" required></div>
</div>
<div class="field">
<label style="color:var(--text)"><input type="checkbox" id="spoolman_fields" style="width:auto"> Zusatzfelder „Drucker“ und „Slot“ in Spoolman pflegen</label>
</div>
<div class="field">
<label style="color:var(--text)"><input type="checkbox" id="dry_run" style="width:auto"> Dry-Run (Schreibaktionen nur protokollieren)</label>
</div>
<div class="row"><button class="primary" type="submit">Speichern</button><span id="saved2" class="small muted"></span></div>
</form>
<h2>SSH-Schlüssel für die Drucker</h2> <form id="settings" onsubmit="event.preventDefault(); saveSettings()">
<div class="panel"> <div class="label section-label">Spoolman &amp; Buchung</div>
<div id="keyinfo" class="field muted">Lade …</div> <div class="group">
<div class="field"> <div class="frow"><label for="spoolman_url">Spoolman-URL</label><input id="spoolman_url" required inputmode="url" autocapitalize="off" spellcheck="false"></div>
<label for="keytext">Privaten Schlüssel einfügen (ed25519, ohne Passphrase) oder Datei wählen – gilt für alle Drucker</label> <div class="frow"><label for="location_slot">Ort im Slot<small><code>{printer}</code> = Druckername, <code>{n}</code> = 1–4</small></label><input id="location_slot" required></div>
<textarea id="keytext" rows="6" placeholder="-----BEGIN OPENSSH PRIVATE KEY-----" autocomplete="off" spellcheck="false"></textarea> <div class="frow"><label for="location_storage">Ort im Lager</label><input id="location_storage" required></div>
<div class="frow"><label for="spoolman_fields">Zusatzfelder „Drucker“ und „Slot“ in Spoolman pflegen</label><span class="switch"><input type="checkbox" id="spoolman_fields"><span></span></span></div>
</div>
<div class="label section-label">Abfrage</div>
<div class="group">
<div class="frow"><label for="poll_interval">Abfrage-Intervall<small>Sekunden</small></label><input id="poll_interval" type="number" step="0.5" min="1" max="300" inputmode="decimal"></div>
<div class="frow"><label for="empty_grace">Slot leer nach<small>Sekunden</small></label><input id="empty_grace" type="number" min="0" inputmode="numeric"></div>
<div class="frow"><label for="settle_seconds">Buchen nach Ruhe<small>Sekunden</small></label><input id="settle_seconds" type="number" min="0" inputmode="numeric"></div>
<div class="frow"><label for="id_offset">ID-Offset<small>weight = Offset + Spoolman-ID</small></label><input id="id_offset" type="number" min="0" max="65000" inputmode="numeric"></div>
</div>
<div class="savebar"><button class="primary" type="submit">Speichern</button><span id="saved" class="small muted"></span></div>
</form>
<div class="label section-label">SSH-Schlüssel für die Drucker</div>
<div class="card stack">
<div id="keyinfo" class="muted">Lade …</div>
<details>
<summary class="small" style="font-weight:700;cursor:pointer;min-height:32px">Schlüssel ersetzen</summary>
<div class="stack" style="padding-top:10px">
<div class="field">
<label for="keytext">Privaten Schlüssel einfügen (ed25519, ohne Passphrase) oder Datei wählen – gilt für alle Drucker</label>
<textarea id="keytext" rows="6" placeholder="-----BEGIN OPENSSH PRIVATE KEY-----" autocomplete="off" spellcheck="false" autocapitalize="off"></textarea>
</div>
<input type="file" id="keyfile" style="padding:8px 12px">
<div class="actions"><button class="primary" onclick="saveKey()">Schlüssel speichern</button><button class="danger" onclick="deleteKey()">Schlüssel löschen</button></div>
</div>
</details>
<p class="small muted" style="margin:0">Der Schlüssel wird mit Dateirechten 600 im Daten-Volume gespeichert und nie wieder angezeigt.</p>
</div> </div>
<div class="row">
<input type="file" id="keyfile" style="flex:1 1 220px">
<button class="primary" onclick="saveKey()">Schlüssel speichern</button>
<button class="danger" onclick="deleteKey()">Schlüssel löschen</button>
</div>
<p class="small muted">Der Schlüssel wird mit Dateirechten 600 im Daten-Volume gespeichert und nie wieder angezeigt.</p>
</div> </div>
{% endblock %} {% endblock %}
{% block script %} {% block script %}
<script> <script>
const FIELDS = ['spoolman_url', 'id_offset', 'poll_interval', 'empty_grace', 'settle_seconds', 'location_slot', 'location_storage']; const FIELDS = ['spoolman_url', 'id_offset', 'poll_interval', 'empty_grace', 'settle_seconds', 'location_slot', 'location_storage'];
const PFIELDS = [['name', 'Name'], ['host', 'Drucker-IP'], ['ssh_port', 'SSH-Port'], ['ssh_user', 'SSH-Benutzer'], ['cc2_url', 'CC2-Control-URL']]; const PFIELDS = [['name', 'Name'], ['host', 'Drucker-IP'], ['ssh_port', 'SSH-Port'], ['ssh_user', 'SSH-Benutzer'], ['cc2_url', 'CC2-Control-URL']];
let current = {}, printers = [], hostKeys = {}, saved = new Set(); const PRINTER_ICON = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 4h16v16H4zM4 9h16M10 13h4"/></svg>';
let current = {}, printers = [], hostKeys = {}, saved = new Set(), openIdx = new Set();
function printerCard(p, i) { function printerItem(p, i) {
const hk = hostKeys[p.id]; const hk = hostKeys[p.id], isSaved = saved.has(p.id);
const isSaved = saved.has(p.id); const chip = !isSaved ? '<span class="chip">nicht gespeichert</span>'
return `<div class="panel" style="margin-bottom:12px" id="pc${i}"> : !p.enabled ? '<span class="chip">pausiert</span>'
<div class="row">${PFIELDS.map(([f, l]) => `<div class="field"><label>${l}</label> : hk ? '<span class="chip ok">SSH ok</span>' : '<span class="chip warn">Host-Key fehlt</span>';
<input data-i="${i}" data-f="${f}" value="${esc(p[f])}" ${f === 'ssh_port' ? 'type="number" min="1" max="65535"' : ''}></div>`).join('')}</div> return `<details class="pr" ${openIdx.has(i) || !p.id ? 'open' : ''} data-i="${i}">
<div class="row" style="align-items:center"> <summary><div class="item"><div class="printer-icon">${PRINTER_ICON}</div>
<label style="margin:0;color:var(--text);flex:0 0 auto"><input type="checkbox" data-i="${i}" data-f="enabled" ${p.enabled ? 'checked' : ''} style="width:auto"> Abfrage aktiv</label> <div class="grow"><div class="title">${esc(p.name || 'Neuer Drucker')}</div><div class="sub mono">${esc(p.host || '–')}</div></div>
<span class="small" style="flex:1 1 200px">${isSaved ? (hk ? `Host-Key <code>${esc(hk.fingerprint)}</code>` : '<span class="tag warn">Host-Key nicht bestätigt</span>') : '<span class="tag">noch nicht gespeichert</span>'}</span> ${chip}${CHEV}</div></summary>
${isSaved ? `<button class="primary" onclick="testSSH(${i})">Verbindung testen</button> <div class="prbody">
<button onclick="forgetHost(${i})">Host-Key vergessen</button>` : ''} <div class="fields">${PFIELDS.map(([f, l]) => `<div class="field"><label for="p${i}${f}">${l}</label>
<button class="danger" onclick="removePrinter(${i})">Entfernen</button> <input id="p${i}${f}" data-i="${i}" data-f="${f}" value="${esc(p[f])}" ${f === 'ssh_port' ? 'type="number" min="1" max="65535" inputmode="numeric"' : 'autocapitalize="off" spellcheck="false"'}></div>`).join('')}</div>
<label class="row" style="gap:10px;font-weight:600"><span class="switch"><input type="checkbox" data-i="${i}" data-f="enabled" ${p.enabled ? 'checked' : ''}><span></span></span>Abfrage aktiv</label>
${isSaved ? `<div class="small">${hk ? `Host-Key <code>${esc(hk.fingerprint)}</code>` : '<span style="color:var(--warn)">Host-Key nicht bestätigt – Verbindung testen.</span>'}</div>` : ''}
<div class="actions">
${isSaved ? `<button class="primary" onclick="testSSH(${i})">Verbindung testen</button>
<button onclick="forgetHost(${i})">Host-Key vergessen</button>` : `<button class="primary" onclick="saveSettings()">Speichern</button>`}
<button class="danger" onclick="removePrinter(${i})">Entfernen</button>
</div>
<div id="test${i}"></div>
</div> </div>
<div id="test${i}" style="margin-top:10px"></div> </details>`;
</div>`;
} }
function renderPrinters() { function renderPrinters() {
$('printers').innerHTML = printers.map(printerCard).join('') || '<div class="panel muted">Kein Drucker eingerichtet.</div>'; $('printers').innerHTML = printers.map(printerItem).join('') || '<div class="item muted">Kein Drucker eingerichtet.</div>';
document.querySelectorAll('#printers details.pr').forEach((d) => d.addEventListener('toggle', () => {
const i = Number(d.dataset.i);
if (d.open) openIdx.add(i); else openIdx.delete(i);
}));
document.querySelectorAll('#printers input[data-f]').forEach((el) => el.addEventListener('input', () => { document.querySelectorAll('#printers input[data-f]').forEach((el) => el.addEventListener('input', () => {
const p = printers[el.dataset.i]; const p = printers[el.dataset.i];
p[el.dataset.f] = el.type === 'checkbox' ? el.checked : el.value; p[el.dataset.f] = el.type === 'checkbox' ? el.checked : el.value;
})); }));
} }
function addPrinter() { function addPrinter() {
printers.push({ id: '', name: '', host: '', ssh_port: 22, ssh_user: 'root', cc2_url: 'http://', enabled: true }); printers.push({ id: '', name: '', host: '', ssh_port: 22, ssh_user: 'root', cc2_url: 'http://', enabled: true });
openIdx.add(printers.length - 1);
renderPrinters(); renderPrinters();
} }
function removePrinter(i) { function removePrinter(i) {
const p = printers[i]; const p = printers[i];
if (!confirm(`Drucker „${p.name || 'neu'}“ entfernen?\n\nSeine Spulen werden nach dem Speichern in Spoolman ins Lager gebucht.`)) return; if (!confirm(`Drucker „${p.name || 'neu'}“ entfernen?\n\nSeine Spulen werden nach dem Speichern in Spoolman ins Lager gebucht.`)) return;
printers.splice(i, 1); printers.splice(i, 1);
openIdx.clear();
renderPrinters(); renderPrinters();
toast('Zum Übernehmen speichern');
}
function showMode(dry) {
$('write').checked = !dry;
$('modetitle').textContent = dry ? 'Dry-Run aktiv' : 'Schreiben aktiv';
setMode(dry);
} }
async function loadSettings() { async function loadSettings() {
current = await api('GET', '/api/settings'); current = await api('GET', '/api/settings');
for (const f of FIELDS) $(f).value = current[f]; for (const f of FIELDS) $(f).value = current[f];
$('dry_run').checked = current.dry_run;
$('spoolman_fields').checked = current.spoolman_fields; $('spoolman_fields').checked = current.spoolman_fields;
printers = current.printers.map((p) => ({ ...p })); printers = current.printers.map((p) => ({ ...p }));
saved = new Set(printers.map((p) => p.id)); saved = new Set(printers.map((p) => p.id));
setMode(current.dry_run); showMode(current.dry_run);
} }
async function saveSettings() { async function saveSettings() {
const body = { printers }; const body = { printers };
for (const f of FIELDS) body[f] = $(f).value; for (const f of FIELDS) body[f] = $(f).value;
body.dry_run = $('dry_run').checked;
body.spoolman_fields = $('spoolman_fields').checked; body.spoolman_fields = $('spoolman_fields').checked;
if (current.dry_run && !body.dry_run && !confirm(
'Dry-Run ausschalten?\n\nDie Bridge legt dann Spulen in CC2 Control an, weist Slots zu, pflegt Ort und Zusatzfelder in Spoolman und bucht Verbrauch.')) return;
try { try {
current = await api('POST', '/api/settings', body); current = await api('POST', '/api/settings', body);
printers = current.printers.map((p) => ({ ...p })); printers = current.printers.map((p) => ({ ...p }));
saved = new Set(printers.map((p) => p.id)); saved = new Set(printers.map((p) => p.id));
setMode(current.dry_run); showMode(current.dry_run);
const t = 'Gespeichert ' + new Date().toLocaleTimeString('de-DE'); $('saved').textContent = 'Gespeichert ' + new Date().toLocaleTimeString('de-DE');
$('saved1').textContent = t; $('saved2').textContent = t; toast('Gespeichert');
await loadSSH(); await loadSSH();
} catch (e) { toast(e.message); } } catch (e) { toast(e.message); }
} }
$('write').addEventListener('change', async () => {
const on = $('write').checked;
if (on && !confirm('Dry-Run ausschalten?\n\nDie Bridge legt dann Spulen in CC2 Control an, weist Slots zu, pflegt Ort und Zusatzfelder in Spoolman und bucht Verbrauch.')) {
$('write').checked = false; return;
}
try {
current = await api('POST', '/api/settings', { dry_run: !on });
showMode(current.dry_run);
toast(current.dry_run ? 'Dry-Run aktiv' : 'Schreiben aktiv');
} catch (e) { toast(e.message); showMode(current.dry_run); }
});
function renderKey(info) { function renderKey(info) {
const k = info.key; const k = info.key;
if (!k) { $('keyinfo').innerHTML = '<span class="tag warn">Kein Schlüssel hinterlegt</span>'; return; } if (!k) { $('keyinfo').innerHTML = '<span class="chip warn">Kein Schlüssel hinterlegt</span>'; return; }
if (k.error) { $('keyinfo').innerHTML = `<span class="tag err">${esc(k.error)}</span>`; return; } if (k.error) { $('keyinfo').innerHTML = `<span class="chip err">${esc(k.error)}</span>`; return; }
$('keyinfo').innerHTML = `<dl class="kv"><dt>Typ</dt><dd>${esc(k.type)}</dd><dt>Fingerprint</dt><dd><code>${esc(k.fingerprint)}</code></dd></dl> $('keyinfo').innerHTML = `<div class="row" style="flex-wrap:nowrap"><div style="flex:1;min-width:0"><div style="font-weight:700;color:var(--ink)">${esc(k.type)}</div>
<p class="small muted" style="margin:10px 0 4px">Zeile für <code>/root/.ssh/authorized_keys</code> auf jedem Drucker (öffentlicher Schlüssel, eingeschränkt):</p> <div class="mono small muted" style="overflow-wrap:anywhere">${esc(k.fingerprint)}</div></div></div>
<p class="small muted" style="margin:12px 0 6px">Zeile für <code>/root/.ssh/authorized_keys</code> auf jedem Drucker (öffentlicher Schlüssel, eingeschränkt):</p>
<pre id="authline">command="cat /opt/usr/canvas_record_info.json",from="10.10.6.10",no-pty,no-port-forwarding,no-agent-forwarding,no-X11-forwarding ${esc(k.public)}</pre> <pre id="authline">command="cat /opt/usr/canvas_record_info.json",from="10.10.6.10",no-pty,no-port-forwarding,no-agent-forwarding,no-X11-forwarding ${esc(k.public)}</pre>
<button style="margin-top:6px" onclick="copyText($('authline').textContent)">Kopieren</button>`; <button style="margin-top:8px" onclick="copyText($('authline').textContent)">Kopieren</button>`;
} }
async function loadSSH() { async function loadSSH() {
@@ -177,10 +237,10 @@ async function testSSH(i) {
}).join(''); }).join('');
let host = `<div class="banner ok">Verbunden. Host-Key <code>${esc(r.host_key.fingerprint)}</code> stimmt mit dem gespeicherten überein.</div>`; let host = `<div class="banner ok">Verbunden. Host-Key <code>${esc(r.host_key.fingerprint)}</code> stimmt mit dem gespeicherten überein.</div>`;
if (!r.host_key_pinned) { if (!r.host_key_pinned) {
host = `<div class="banner warn">Neuer Host-Key: <code>${esc(r.host_key.fingerprint)}</code> (${esc(r.host_key.type)}). Vergleichen und bestätigen. host = `<div class="banner warn"><span style="flex:1 1 240px">Neuer Host-Key: <code>${esc(r.host_key.fingerprint)}</code> (${esc(r.host_key.type)}). Vergleichen und bestätigen.</span>
<button class="primary" onclick="acceptHost(${i}, '${esc(r.host_key.fingerprint)}')">Bestätigen und speichern</button></div>`; <button class="primary" onclick="acceptHost(${i}, '${esc(r.host_key.fingerprint)}')">Bestätigen und speichern</button></div>`;
} }
out.innerHTML = host + `<div class="table-wrap"><table><thead><tr><th>Slot</th><th>Tag</th><th>weight</th><th>Spoolman-ID</th></tr></thead><tbody>${rows}</tbody></table></div>`; out.innerHTML = host + `<div class="table-wrap" style="margin-top:10px"><table><thead><tr><th>Slot</th><th>Tag</th><th>weight</th><th>Spoolman-ID</th></tr></thead><tbody>${rows}</tbody></table></div>`;
} }
async function acceptHost(i, fp) { async function acceptHost(i, fp) {
+314 -71
View File
@@ -1,68 +1,307 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}CC2 Bridge – Status{% endblock %} {% block title %}CC2 Bridge – Status{% endblock %}
{% block heading %}Status{% endblock %}
{% block head %}
<style>
.layout { display: flex; flex-wrap: wrap; gap: 28px; align-items: flex-start; }
.printers { flex: 999 1 640px; min-width: 0; display: flex; flex-direction: column; gap: 32px; }
.aside { flex: 1 1 300px; min-width: 0; padding: 18px 18px 8px; }
.phead { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
.phead h2 { font-size: 24px; letter-spacing: -.02em; }
.phead .chip { margin-left: auto; font-size: 13px; padding: 5px 12px; }
.printer { display: flex; flex-direction: column; gap: 14px; }
.job { background: var(--hero); color: var(--hero-ink); border-radius: var(--radius); padding: 18px 20px; display: flex; flex-direction: column; gap: 12px; }
.job .k { font-size: 12px; color: var(--hero-muted); }
.job .v { font-family: var(--mono); font-size: 20px; }
.job .file { font-weight: 700; font-size: 16px; overflow-wrap: anywhere; }
.job .stats { display: flex; gap: 28px; flex-wrap: wrap; }
.job .bar { height: 6px; border-radius: 99px; background: var(--hero-track); overflow: hidden; }
.job .bar div { height: 6px; border-radius: 99px; background: var(--hero-accent); }
.job .note { font-size: 13px; color: var(--hero-muted); }
.job .label { color: var(--hero-muted); }
.slots { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 12px; }
.slot { display: flex; flex-direction: column; gap: 14px; min-height: 236px; }
.slot .sh { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.slot .sh b { font-size: 14px; font-weight: 800; }
.spool { display: flex; gap: 14px; align-items: center; text-align: left; border: 0; background: none; padding: 0; min-height: 0;
border-radius: 12px; font-weight: inherit; color: inherit; width: 100%; }
.spool:hover .nm { text-decoration: underline; }
.spool .nm { font-weight: 800; font-size: 16px; overflow-wrap: anywhere; }
.facts { display: flex; flex-direction: column; gap: 6px; font-size: 13px; }
.facts div { display: flex; justify-content: space-between; gap: 8px; }
.facts span:first-child { color: var(--muted); }
.facts span:last-child { text-align: right; }
.slot .acts { margin-top: auto; display: flex; gap: 8px; }
.slot .acts button { flex: 1; font-size: 13px; min-height: 36px; padding: 0 8px; }
.emptybox { flex: 1; border: 2px dashed var(--line); border-radius: 12px; display: flex; align-items: center; justify-content: center;
color: var(--muted); font-size: 13px; font-weight: 600; min-height: 100px; }
.dashed { border: 3px dashed var(--faint); }
.hint { margin: 0; font-size: 13px; color: var(--muted); }
.hint.warn { color: var(--warn); }
.seg { display: grid; gap: 4px; background: var(--line); padding: 4px; border-radius: 12px; }
.seg button { border: 0; min-height: 36px; border-radius: 9px; background: transparent; color: var(--muted); font-weight: 600; }
.seg button[aria-selected=true] { background: var(--surface); color: var(--ink); font-weight: 700; box-shadow: 0 1px 2px rgba(0,0,0,.08); }
.seg button::before { content: ""; width: 7px; height: 7px; border-radius: 99px; background: var(--dot, var(--faint)); }
.logrow { display: flex; gap: 12px; padding: 12px 0; border-top: 1px solid var(--line2); }
.logrow .msg { font-size: 14px; overflow-wrap: anywhere; }
.logrow .meta { font-size: 12px; color: var(--muted); }
.lastbook { display: flex; gap: 12px; align-items: center; }
@media (max-width: 759px) {
.printer:not(.sel) { display: none; }
.printers { gap: 14px; }
.phead { display: none; }
.single .phead { display: flex; }
.job { padding: 16px; }
.job .stats { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }
.job .v { font-size: 17px; }
}
</style>
{% endblock %}
{% block content %} {% block content %}
<div id="banners"></div> <div id="banners" class="stack" style="margin-bottom:14px"></div>
<div id="printers"><div class="panel muted">Lade …</div></div> <div id="seg" class="seg phone-only" role="tablist" aria-label="Drucker" style="margin-bottom:14px"></div>
<p class="small muted" id="lastpoll"></p> <div class="layout">
<h2>Protokoll</h2> <div id="printers" class="printers"><div class="card muted">Lade …</div></div>
<div class="panel table-wrap"> <aside class="card aside desktop-only" aria-label="Protokoll">
<table><thead><tr><th style="width:170px">Zeit</th><th style="width:80px"></th><th>Aktion</th></tr></thead> <div class="row" style="justify-content:space-between;margin-bottom:6px"><h2 style="font-size:17px">Protokoll</h2><a href="/log" class="small" style="font-weight:700;text-decoration:none">Alle</a></div>
<tbody id="log"></tbody></table> <div id="log"></div>
</aside>
</div> </div>
<dialog id="sheet" class="sheet" aria-labelledby="sheet-title"><div class="sheet-in" id="sheet-in"></div></dialog>
{% endblock %} {% endblock %}
{% block script %} {% block script %}
<script> <script>
const QUESTION = { inserted: 'eingelegt', changed: 'geändert' }; const QUESTION = { inserted: 'eingelegt', changed: 'geändert' };
const TRAY = { 0: ['leer', 'warn'], 1: ['eingelegt', 'ok'], 2: ['fördert', 'ok'] }; const TRAY = { 0: ['leer', ''], 1: ['eingelegt', 'ok'], 2: ['fördert', 'accent'] };
const LEVEL = { info: ['Info', ''], warn: ['Warnung', 'warn'], error: ['Fehler', 'err'], dry: ['Dry-Run', 'dry'] }; let ST = null, NAMES = {}, SEL = null, SHEET = null;
try { SEL = localStorage.getItem('printer'); } catch (e) {}
function colorOf(ch) { function hex(c) { c = String(c || '').replace(/^0x|^#/i, ''); return /^[0-9a-f]{6}$/i.test(c) ? '#' + c : null; }
const c = (ch.color || '').replace(/^0x/i, '');
return /^[0-9a-f]{6}$/i.test(c) ? c : null; function kindOf(s) {
if (s.mapping) return 'managed';
if (s.takeout) return 'takeout';
if (s.tray_status === 1 || s.tray_status === 2) return 'unmanaged';
return 'empty';
} }
function slotCard(p, s) { function info(s) {
const ch = s.record.channel || {}; const m = s.mapping, sm = s.spoolman, cc = s.cc2_spool, ch = s.record.channel || {};
const tray = TRAY[s.tray_status]; const kind = kindOf(s);
const col = colorOf(ch); const tagColor = hex(ch.color);
const m = s.mapping, sm = s.spoolman, cc = s.cc2_spool; const out = { kind, tray: TRAY[s.tray_status] };
let tag = '<span class="muted">kein Tag</span>'; if (kind === 'managed') {
if (s.record.occupied) { const rest = cc ? Number(cc.remaining) : (sm && sm.remaining != null ? Number(sm.remaining) : null);
tag = `${col ? `<span class="swatch" style="background:#${col}"></span> ` : ''}${esc(ch.detailed_type || ch.type)}` + const initial = (sm && sm.initial) || 1000;
` <span class="muted small">${esc(ch.manufacturer)}, weight ${esc(ch.weight)}</span>`; out.color = (sm && hex(sm.color)) || tagColor || '#9AA3B0';
out.rest = rest; out.initial = initial;
out.pct = rest == null ? 0 : Math.max(0, Math.min(100, rest / initial * 100));
out.title = m.name || ('Spule ' + m.spool);
out.sub = sm ? [sm.vendor, sm.material].filter(Boolean).join(' ') : '';
if (s.cc2_matches) out.cc2 = ['zugewiesen', 'ok'];
else out.cc2 = m.cc2 ? ['nicht zugewiesen', 'warn'] : ['noch nicht angelegt', 'warn'];
if (s.cc2_question) out.question = 'CC2 fragt: Spule ' + (QUESTION[s.cc2_question] || s.cc2_question);
} else if (kind === 'takeout') {
out.title = s.takeout.name; out.color = tagColor || '#9AA3B0';
out.sub = 'In CC2 Control herausgenommen';
out.hint = `${s.takeout.name} steht in Spoolman im Lager, steckt aber noch. Was dieser Slot druckt, bucht die Bridge trotzdem auf ${s.takeout.name}, bis die Rolle entnommen wird. Wieder zuordnen: Rolle herausnehmen und neu einlegen.`;
} else if (kind === 'unmanaged') {
out.color = tagColor;
out.sub = s.record.occupied ? [ch.manufacturer, ch.detailed_type || ch.type].filter(Boolean).join(' ') : 'Rolle ohne Tag-Daten';
if (s.record.spool_id != null) {
out.title = 'Tag ' + s.record.spool_id + ' ohne Zuordnung';
out.hint = `Tag ${s.record.spool_id} steht noch im Slot, ist aber keiner Rolle zugeordnet. Liegt diese Rolle hier, sie herausnehmen und neu einlegen (oder in CC2 Control auswählen).`;
} else {
out.title = 'Ohne Spoolman-Tag';
out.hint = 'Verbrauch wird nicht gebucht. Rolle taggen und scannen, um sie zu verwalten.';
}
} }
let assign = '<span class="muted">–</span>'; return out;
if (m) { }
assign = s.cc2_matches ? '<span class="tag ok">zugewiesen</span>'
: (m.cc2 ? '<span class="tag warn">nicht zugewiesen</span>' : '<span class="tag warn">noch nicht angelegt</span>'); function ringHtml(i, size, hole, inner) {
} const bg = i.kind === 'managed'
if (s.cc2_question) assign += ` <span class="tag warn">CC2 fragt: Spule ${esc(QUESTION[s.cc2_question] || s.cc2_question)}</span>`; ? `conic-gradient(${i.color} 0 ${i.pct}%, var(--track) ${i.pct}% 100%)`
const orphan = !m && !s.takeout && s.record.spool_id != null && (s.tray_status === 1 || s.tray_status === 2); : (i.kind === 'empty' ? 'var(--bg)' : 'var(--surface)');
const lb = s.last_booking; const cls = i.kind === 'unmanaged' || i.kind === 'takeout' ? ' dashed' : '';
return `<div class="panel"> return `<div class="ring${cls}" style="width:${size}px;height:${size}px;background:${bg}" aria-hidden="true">
<h3>Slot ${s.slot + 1} ${tray ? `<span class="tag ${tray[1]}">${tray[0]}</span>` : ''}</h3> <div style="width:${hole}px;height:${hole}px">${inner}</div></div>`;
<p class="small muted" style="margin:-4px 0 8px">Ort in Spoolman: ${esc(s.location)}</p> }
<dl class="kv">
<dt>Tag</dt><dd>${tag}</dd> function stateOf(p) {
<dt>Tag-ID</dt><dd>${s.record.spool_id ?? '<span class="muted">–</span>'}</dd> if (!p.enabled) return ['Pausiert', ''];
<dt>Spoolman</dt><dd>${m ? `${sm && sm.color ? `<span class="swatch" style="background:#${sm.color}"></span> ` : ''}<b>${esc(m.name || m.spool)}</b> <span class="muted">#${m.spool}</span>` + if (Object.values(p.errors || {}).some(Boolean)) return ['Fehler', 'err'];
(sm ? `<br><span class="small muted">${esc(sm.vendor)} ${esc(sm.material)}, Rest ${sm.remaining != null ? Math.round(sm.remaining) + ' g' : '?'}</span>` : '') : '<span class="muted">nicht zugeordnet</span>'}</dd> if (p.cc2_enabled === false) return ['Verwaltung aus', 'warn'];
<dt>CC2 Control</dt><dd>${cc ? esc(cc.name) + ` <span class="small muted">Rest ${Number(cc.remaining).toFixed(1)} g</span><br>` : ''}${assign}</dd> if (p.job_active) return ['Druckt', 'accent'];
<dt>Buchung</dt><dd>${lb ? `${lb.grams.toFixed(2)} g <span class="small muted">${fmtTime(lb.time)}</span>` + return ['Bereit', 'ok'];
(lb.details ? `<br><span class="small muted">${esc(lb.details)}</span>` : '') : '<span class="muted">–</span>'}</dd> }
</dl>
${s.takeout ? `<p class="small" style="margin:10px 0 0;color:var(--warn)">${esc(s.takeout.name)} wurde in CC2 Control herausgenommen function fmtLeft(sec) {
und steht in Spoolman im Lager, steckt aber noch. Was dieser Slot druckt, bucht die Bridge trotzdem auf ${esc(s.takeout.name)}, if (sec == null || sec < 0) return '–';
bis die Rolle entnommen wird. Wieder zuordnen: Rolle herausnehmen und neu einlegen.</p>` : ''} const h = Math.floor(sec / 3600), m = Math.round((sec % 3600) / 60);
${orphan ? `<p class="small" style="margin:10px 0 0;color:var(--warn)">Tag ${s.record.spool_id} steht noch im Slot, ist aber keiner Rolle return h ? `${h}:${String(m).padStart(2, '0')} h` : `${m} min`;
zugeordnet. Liegt diese Rolle hier, sie herausnehmen und neu einlegen (oder in CC2 Control auswählen).</p>` : ''} }
${m ? `<div class="row" style="margin-top:10px">
<button onclick="slotAction('${p.id}', ${s.slot}, 'reassign')">Zuordnung erneut senden</button> function jobCard(p) {
<button class="danger" onclick="slotAction('${p.id}', ${s.slot}, 'release')">Aufheben</button></div>` : ''} const j = p.job;
if (!j) return '';
const prog = j.progress != null ? Math.round(j.progress) : null;
return `<div class="job">
<div class="row" style="justify-content:space-between;gap:8px">
<span class="label">Druck läuft</span>
${j.layer ? `<span class="mono small" style="color:var(--hero-muted)">Schicht ${j.layer}/${j.layers || '?'}</span>` : ''}
</div>
<div class="file">${esc(j.file || 'Unbekannte Datei')}</div>
${prog != null ? `<div class="bar"><div style="width:${prog}%"></div></div>` : ''}
<div class="stats">
<div><div class="k">Fortschritt</div><div class="v">${prog != null ? prog + '%' : '–'}</div></div>
<div><div class="k">Restzeit</div><div class="v">${fmtLeft(j.remaining)}</div></div>
<div><div class="k">Verbraucht</div><div class="v">${fmtNum(j.grams, 1)} g</div></div>
</div>
<div class="note">Wird nach Druckende auf die Spoolman-Spulen gebucht.</div>
</div>`; </div>`;
} }
let NAMES = {}; function slotCard(p, s) {
const i = info(s), lb = s.last_booking;
const tray = i.tray ? `<span class="chip ${i.tray[1]}">${i.tray[0]}</span>` : '';
let body;
if (i.kind === 'managed') {
body = `<button class="spool" onclick="openSheet('${p.id}', ${s.slot})">
${ringHtml(i, 76, 54, `<span class="mono" style="font-size:14px;line-height:1">${i.rest == null ? '?' : Math.round(i.rest)}</span><span style="font-size:10px;color:var(--muted)">g</span>`)}
<span style="min-width:0"><span class="nm" style="display:block">${esc(i.title)} <span class="muted small" style="font-weight:500">#${s.mapping.spool}</span></span>
<span class="small muted" style="display:block">${esc(i.sub)}</span>
<span class="small muted" style="display:block">von ${Math.round(i.initial)} g</span></span>
</button>
<div class="facts">
<div><span>CC2 Control</span><span style="font-weight:700;color:var(--${i.cc2[1]})">${i.cc2[0]}</span></div>
${i.question ? `<div><span></span><span class="chip warn">${esc(i.question)}</span></div>` : ''}
<div><span>Zuletzt gebucht</span><span class="mono">${lb ? fmtNum(lb.grams, 2) + ' g · ' + fmtClock(lb.time) : '–'}</span></div>
</div>
<div class="acts">
<button onclick="slotAction('${p.id}', ${s.slot}, 'reassign')">Erneut senden</button>
<button class="danger" onclick="slotAction('${p.id}', ${s.slot}, 'release')">Aufheben</button>
</div>`;
} else if (i.kind === 'empty') {
body = '<div class="emptybox">Kein Filament</div>';
} else {
body = `<button class="spool" onclick="openSheet('${p.id}', ${s.slot})">
${ringHtml(i, 76, 54, i.color ? `<span class="swatch" style="width:22px;height:22px;background:${i.color}"></span>` : '')}
<span style="min-width:0"><span class="nm" style="display:block;font-size:15px">${esc(i.title)}</span>
<span class="small muted" style="display:block">${esc(i.sub)}</span></span>
</button>
<p class="hint${i.kind === 'takeout' ? ' warn' : ''}" style="margin-top:auto">${esc(i.hint)}</p>`;
}
return `<article class="card slot"><div class="sh"><b>Slot ${s.slot + 1}</b>${tray}</div>${body}</article>`;
}
function slotRow(p, s) {
const i = info(s);
const inner = `<span style="font-weight:800;font-size:13px">${s.slot + 1}</span>`;
let title = i.title, sub = i.sub, rest = '';
if (i.kind === 'empty') { title = 'Leer'; sub = 'Kein Filament'; }
if (i.kind === 'managed') {
rest = i.rest == null ? '?' : Math.round(i.rest) + ' g';
sub = [i.sub, '#' + s.mapping.spool, i.question || i.cc2[0]].filter(Boolean).join(' · ');
}
const tray = i.tray || ['', ''];
const trayColor = { ok: 'var(--ok)', accent: 'var(--accent)' }[tray[1]] || 'var(--muted)';
const tag = i.kind === 'empty' ? 'div' : 'button';
return `<${tag} class="item" ${tag === 'button' ? `onclick="openSheet('${p.id}', ${s.slot})"` : ''}>
${ringHtml(i, 48, 32, inner)}
<span class="grow"><span class="title" style="display:block">${esc(title)}</span><span class="sub" style="display:block">${esc(sub)}</span></span>
<span style="text-align:right"><span class="mono" style="display:block">${rest}</span><span style="display:block;font-size:12px;font-weight:700;color:${trayColor}">${tray[0]}</span></span>
${tag === 'button' ? CHEV : '<span style="width:8px"></span>'}
</${tag}>`;
}
function lastBooking(p) {
const b = p.slots.map((s) => s.last_booking && { ...s.last_booking, name: s.mapping && s.mapping.name, spool: s.mapping && s.mapping.spool })
.filter(Boolean).sort((a, b) => b.time - a.time)[0];
if (!b) return '';
return `<div class="card lastbook phone-only">
<div class="icon-badge" style="background:var(--ok-bg);color:var(--ok)">g</div>
<div style="min-width:0"><div class="small muted">Letzte Buchung · ${fmtClock(b.time)}</div>
<div style="font-weight:700;font-size:14px">${fmtNum(b.grams, 2)} g auf ${esc(b.name || '')}${b.spool ? ` (Spoolman ${b.spool})` : ''}</div>
${b.details ? `<div class="small muted" style="overflow-wrap:anywhere">${esc(b.details)}</div>` : ''}</div>
</div>`;
}
function printerSection(p) {
const st = stateOf(p), pb = [];
if (!p.enabled) pb.push('<div class="banner warn">Abfrage pausiert (in den Einstellungen deaktiviert).</div>');
else if (ST.ssh_key && !p.host_key) pb.push('<div class="banner warn">Host-Key noch nicht bestätigt. <a href="/settings">Verbindung testen</a></div>');
if (p.cc2_enabled === false) pb.push(`<div class="banner err"><span style="flex:1 1 220px">Spulenverwaltung in CC2 Control ist ausgeschaltet. Verbrauch wird nicht gezählt.</span><button class="primary" onclick="enableCC2('${p.id}')">Einschalten …</button></div>`);
for (const v of Object.values(p.errors || {})) if (v) pb.push(`<div class="banner err">${esc(v)}</div>`);
return `<section class="printer${p.id === SEL ? ' sel' : ''}" aria-label="${esc(p.name)}">
<div class="phead"><h2>${esc(p.name)}</h2><span class="mono small muted host">${esc(p.host)}</span><span class="chip dot ${st[1]}">${st[0]}</span></div>
${pb.join('')}
${jobCard(p)}
<div class="slots desktop-only">${p.slots.map((s) => slotCard(p, s)).join('')}</div>
<div class="group phone-only">${p.slots.map((s) => slotRow(p, s)).join('')}</div>
${lastBooking(p)}
</section>`;
}
function renderSeg(printers) {
const seg = $('seg');
if (printers.length < 2) { seg.style.display = 'none'; return; }
seg.style.display = '';
seg.style.gridTemplateColumns = `repeat(${printers.length}, minmax(0, 1fr))`;
const dot = { ok: 'var(--ok)', accent: 'var(--accent)', err: 'var(--err)', warn: 'var(--warn)' };
seg.innerHTML = printers.map((p) => `<button role="tab" aria-selected="${p.id === SEL}" style="--dot:${dot[stateOf(p)[1]] || 'var(--faint)'}"
onclick="selectPrinter('${p.id}')">${esc(p.name)}</button>`).join('');
}
function selectPrinter(id) {
SEL = id;
try { localStorage.setItem('printer', id); } catch (e) {}
render();
}
function sheetHtml(p, s) {
const i = info(s), m = s.mapping, sm = s.spoolman, cc = s.cc2_spool, ch = s.record.channel || {}, lb = s.last_booking;
const rows = [];
rows.push(['Tag', s.record.occupied ? `${hex(ch.color) ? `<span class="swatch" style="background:${hex(ch.color)}"></span> ` : ''}${esc(ch.detailed_type || ch.type)} <span class="muted small">${esc(ch.manufacturer)}</span>` : '<span class="muted">kein Tag</span>']);
rows.push(['Tag-ID', s.record.spool_id ?? '<span class="muted">–</span>']);
if (m) rows.push(['Spoolman', `${sm && sm.color ? `<span class="swatch" style="background:#${sm.color}"></span> ` : ''}<b>${esc(m.name || m.spool)}</b> <span class="muted">#${m.spool}</span>` +
(sm ? `<br><span class="small muted">${esc(sm.vendor)} ${esc(sm.material)}${sm.name ? ' · ' + esc(sm.name) : ''}, Rest ${sm.remaining != null ? Math.round(sm.remaining) + ' g' : '?'}</span>` : '')]);
rows.push(['CC2 Control', (cc ? `${esc(cc.name)} <span class="small muted">Rest ${fmtNum(cc.remaining, 1)} g</span><br>` : '') +
(i.cc2 ? `<span class="chip ${i.cc2[1]}">${i.cc2[0]}</span>` : '<span class="muted">–</span>') + (i.question ? ` <span class="chip warn">${esc(i.question)}</span>` : '')]);
rows.push(['Ort in Spoolman', esc(s.location)]);
rows.push(['Buchung', lb ? `${fmtNum(lb.grams, 2)} g <span class="small muted">${fmtTime(lb.time)}</span>${lb.details ? `<br><span class="small muted">${esc(lb.details)}</span>` : ''}` : '<span class="muted">–</span>']);
const inner = i.kind === 'managed' ? `<span class="mono" style="font-size:15px;line-height:1">${i.rest == null ? '?' : Math.round(i.rest)}</span><span style="font-size:10px;color:var(--muted)">g</span>` : '';
return `<div class="grip"></div>
<div class="row" style="flex-wrap:nowrap;gap:14px">
${ringHtml(i, 64, 44, inner)}
<div style="flex:1;min-width:0"><div class="small muted">${esc(p.name)} · Slot ${s.slot + 1}${i.tray ? ' · ' + i.tray[0] : ''}</div>
<h2 id="sheet-title" style="overflow-wrap:anywhere">${esc(i.title || 'Leer')}</h2>${i.sub ? `<div class="small muted">${esc(i.sub)}</div>` : ''}</div>
<button aria-label="Schließen" onclick="closeSheet()" style="min-width:40px;padding:0;border-radius:99px">✕</button>
</div>
${i.hint ? `<p class="hint${i.kind === 'takeout' ? ' warn' : ''}">${esc(i.hint)}</p>` : ''}
<dl class="kv">${rows.map(([k, v]) => `<dt>${k}</dt><dd>${v}</dd>`).join('')}</dl>
${m ? `<div class="row" style="flex-wrap:nowrap"><button style="flex:1;min-height:48px" onclick="slotAction('${p.id}', ${s.slot}, 'reassign')">Erneut senden</button>
<button class="danger" style="flex:1;min-height:48px" onclick="slotAction('${p.id}', ${s.slot}, 'release')">Aufheben</button></div>` : ''}`;
}
function openSheet(pid, slot) {
SHEET = { pid, slot };
renderSheet();
if (!$('sheet').open) $('sheet').showModal();
}
function renderSheet() {
if (!SHEET || !ST) return;
const p = ST.printers.find((x) => x.id === SHEET.pid);
if (!p) { closeSheet(); return; }
$('sheet-in').innerHTML = sheetHtml(p, p.slots[SHEET.slot]);
}
function closeSheet() { SHEET = null; if ($('sheet').open) $('sheet').close(); }
$('sheet').addEventListener('close', () => { SHEET = null; });
$('sheet').addEventListener('click', (ev) => { if (ev.target === $('sheet')) closeSheet(); });
async function slotAction(pid, slot, action) { async function slotAction(pid, slot, action) {
const name = NAMES[pid] || pid; const name = NAMES[pid] || pid;
const text = action === 'release' const text = action === 'release'
@@ -83,33 +322,37 @@ async function enableCC2(pid) {
} catch (e) { toast(e.message); } } catch (e) { toast(e.message); }
} }
async function load() { function render() {
let st; const st = ST, printers = st.printers || [];
try { st = await api('GET', '/api/status'); } catch (e) { $('banners').innerHTML = `<div class="banner err">${esc(e.message)}</div>`; return; } if (!printers.some((p) => p.id === SEL)) SEL = printers.length ? printers[0].id : null;
setMode(st.dry_run);
const b = []; const b = [];
if (st.dry_run) b.push('<div class="banner dry">Dry-Run aktiv: Schreibaktionen werden nur protokolliert („würde …“). Umschalten unter Einstellungen.</div>'); if (st.dry_run) b.push('<div class="banner dry">Dry-Run aktiv: Schreibaktionen werden nur protokolliert („würde …“). Umschalten unter Einstellungen.</div>');
if (!st.ssh_key) b.push('<div class="banner warn">Kein SSH-Schlüssel hinterlegt. <a href="/settings">Einstellungen</a></div>'); if (!st.ssh_key) b.push('<div class="banner warn">Kein SSH-Schlüssel hinterlegt. <a href="/settings">Einstellungen</a></div>');
if (st.spoolman_error) b.push(`<div class="banner err">${esc(st.spoolman_error)}</div>`); if (st.spoolman_error) b.push(`<div class="banner err">${esc(st.spoolman_error)}</div>`);
$('banners').innerHTML = b.join(''); $('banners').innerHTML = b.join('');
NAMES = Object.fromEntries((st.printers || []).map((p) => [p.id, p.name])); $('banners').style.display = b.length ? '' : 'none';
if (st.printers) $('printers').innerHTML = st.printers.map((p) => { renderSeg(printers);
const pb = []; $('printers').classList.toggle('single', printers.length < 2);
if (!p.enabled) pb.push('<div class="banner warn">Abfrage pausiert (in den Einstellungen deaktiviert).</div>'); $('printers').innerHTML = printers.map(printerSection).join('')
else if (st.ssh_key && !p.host_key) pb.push('<div class="banner warn">Host-Key noch nicht bestätigt. <a href="/settings">Verbindung testen</a></div>'); || '<div class="card muted">Kein Drucker eingerichtet. <a href="/settings">Einstellungen</a></div>';
if (p.cc2_enabled === false) pb.push(`<div class="banner err"><b>Spulenverwaltung in CC2 Control ist ausgeschaltet.</b> Verbrauch wird nicht gezählt. <button class="primary" onclick="enableCC2('${p.id}')">Einschalten …</button></div>`); $('log').innerHTML = (st.log || []).slice(0, 8).map((e) => {
if (p.job_active) pb.push('<div class="banner ok">Druck läuft – Verbrauch wird nach dem Druck gebucht.</div>'); const [ic, bg, fg] = logIcon(e), [pn, msg] = splitPrinter(e.msg, Object.values(NAMES));
for (const v of Object.values(p.errors || {})) if (v) pb.push(`<div class="banner err">${esc(v)}</div>`); return `<div class="logrow"><div class="icon-badge" style="background:${bg};color:${fg}">${ic}</div>
return `<h2>${esc(p.name)} <span class="small muted" style="font-weight:400">${esc(p.host)}</span></h2>${pb.join('')} <div style="min-width:0"><div class="msg">${esc(msg)}</div><div class="meta">${esc(pn)} · ${fmtClock(e.time)}</div></div></div>`;
<div class="grid">${p.slots.map((s) => slotCard(p, s)).join('')}</div>`; }).join('') || '<p class="muted small">Noch keine Einträge</p>';
}).join('') || '<div class="panel muted">Kein Drucker eingerichtet. <a href="/settings">Einstellungen</a></div>'; renderSheet();
$('lastpoll').textContent = st.last_poll ? 'Letzte Abfrage: ' + fmtTime(st.last_poll) : 'Noch keine Abfrage'; }
$('log').innerHTML = (st.log || []).map((e) => {
const l = LEVEL[e.level] || [e.level, '']; async function load() {
return `<tr><td class="small muted">${fmtTime(e.time)}</td><td><span class="tag ${l[1]}">${l[0]}</span></td><td>${esc(e.msg)}</td></tr>`; try { ST = await api('GET', '/api/status'); }
}).join('') || '<tr><td colspan="3" class="muted">Noch keine Einträge</td></tr>'; catch (e) { $('banners').style.display = ''; $('banners').innerHTML = `<div class="banner err">${esc(e.message)}</div>`; return; }
setMode(ST.dry_run);
setPoll(ST.last_poll);
NAMES = Object.fromEntries((ST.printers || []).map((p) => [p.id, p.name]));
render();
} }
load(); load();
setInterval(load, 3000); setInterval(() => { if (!document.hidden) load(); }, 3000);
document.addEventListener('visibilitychange', () => { if (!document.hidden) load(); });
</script> </script>
{% endblock %} {% endblock %}
+141 -66
View File
@@ -1,70 +1,122 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}CC2 Bridge – Tag-Generator{% endblock %} {% block title %}CC2 Bridge – Tag-Generator{% endblock %}
{% block heading %}Tag-Generator{% endblock %}
{% block head %}
<style>
.tg { display: grid; grid-template-columns: minmax(0, 5fr) minmax(0, 6fr); gap: 20px; align-items: start; }
@media (max-width: 999px) { .tg { grid-template-columns: minmax(0, 1fr); } }
.picker { display: flex; flex-direction: column; gap: 12px; }
.spools { max-height: 560px; overflow-y: auto; margin: 0 -16px -16px; border-top: 1px solid var(--line2); }
.spools .item { padding-left: 16px; padding-right: 16px; }
.spools .item[aria-current=true] { background: var(--accent-bg); }
.dot { width: 32px; height: 32px; flex: 0 0 32px; border-radius: 99px; border: 1px solid var(--line); }
.chosen { display: none; }
.palette { display: flex; flex-wrap: wrap; gap: 8px; }
.palette button { width: 32px; height: 32px; min-height: 0; padding: 0; border-radius: 99px; border: 1px solid var(--line); }
.palette button[aria-pressed=true] { box-shadow: 0 0 0 2px var(--surface), 0 0 0 4px var(--accent); }
.cmd { background: var(--hero); color: var(--hero-ink); border-radius: var(--radius); padding: 16px; display: flex; flex-direction: column; gap: 12px; }
.cmd .label { color: var(--hero-muted); }
.cmd textarea { background: transparent; border: 0; color: #D6DCE5; padding: 0; min-height: 0; resize: none; line-height: 1.6; font-size: 12px; word-break: break-all; }
.cmd .acts { display: grid; grid-template-columns: 2fr 1fr; gap: 8px; }
.cmd .acts button { min-height: 48px; }
.cmd .primary { background: var(--hero-accent); border-color: var(--hero-accent); color: #0B1230; font-weight: 800; }
.cmd .ghost { background: transparent; border-color: var(--hero-line); color: var(--hero-ink); }
.cmd .check { font-size: 13px; color: var(--hero-muted); }
.qr { background: #fff; padding: 10px; border-radius: 12px; width: max-content; max-width: 100%; overflow: auto; }
details > summary { cursor: pointer; font-weight: 700; list-style: none; display: flex; align-items: center; justify-content: space-between; min-height: 44px; }
details > summary::-webkit-details-marker { display: none; }
details > summary::after { content: ""; width: 8px; height: 8px; border-right: 2px solid var(--faint); border-bottom: 2px solid var(--faint); transform: rotate(45deg); margin: 0 4px 4px; }
details[open] > summary::after { transform: rotate(-135deg); margin-bottom: -4px; }
.radio { display: flex; gap: 10px; align-items: flex-start; font-size: 14px; padding: 6px 0; }
.radio input { width: 20px; min-height: 20px; height: 20px; margin: 1px 0 0; flex: 0 0 20px; }
.steps { margin: 0; padding-left: 20px; font-size: 13px; color: var(--muted); display: flex; flex-direction: column; gap: 4px; }
@media (max-width: 999px) {
.tg.picked .picker .listcard { display: none; }
.tg.picked .chosen { display: flex; }
}
</style>
{% endblock %}
{% block content %} {% block content %}
<div class="banner err"><b>Wichtig:</b> In NFC Tools nie „Lock tag“ oder „Set password“ verwenden – der Tag wäre danach nicht mehr beschreibbar.</div> <div class="tg" id="tg">
<h2>Spoolman-Spulen</h2> <div class="picker">
<div class="panel"> <button class="card item chosen" id="chosen" onclick="unpick()" aria-label="Andere Spule wählen"></button>
<div class="row" style="margin-bottom:10px"> <div class="card listcard">
<div class="field" style="margin:0"><label for="filter">Filter</label><input id="filter" placeholder="Label, Hersteller, Material …"></div> <div class="label" style="margin-bottom:10px">Spoolman-Spule</div>
<label style="flex:0 0 auto;margin:0 0 8px"><input type="checkbox" id="archived" style="width:auto"> archivierte zeigen</label> <div class="row" style="flex-wrap:nowrap;margin-bottom:12px">
</div> <input id="filter" type="search" placeholder="Suchen: Label, Hersteller, Material …" aria-label="Spulen filtern">
<div class="table-wrap" style="max-height:340px;overflow-y:auto"> </div>
<table><thead><tr><th>ID</th><th>Label</th><th>Hersteller</th><th>Material</th><th>Farbe</th><th>Rest</th><th>Ort</th><th></th></tr></thead> <label class="row small muted" style="gap:10px;margin-bottom:12px"><span class="switch"><input type="checkbox" id="archived"><span></span></span>Archivierte zeigen</label>
<tbody id="spools"><tr><td colspan="8" class="muted">Lade …</td></tr></tbody></table> <div class="spools" id="spools"><p class="muted" style="padding:0 16px">Lade …</p></div>
</div>
</div>
<div id="editor" style="display:none">
<h2 id="edtitle">Tag</h2>
<div class="panel">
<div class="row">
<div class="field"><label for="material">Elegoo-Material</label><select id="material"></select></div>
<div class="field"><label for="subtype">Untertyp (wählt das Slicer-Profil)</label><select id="subtype"></select></div>
</div>
<div class="row">
<div class="field"><label for="color">Tag-Farbe (RRGGBB)</label>
<div class="row" style="gap:6px;flex-wrap:nowrap"><input id="color" maxlength="7" class="mono"><input type="color" id="picker" style="width:48px;padding:2px"></div></div>
<div class="field"><label for="temp_min">Düse min (°C)</label><input id="temp_min" type="number" min="0" max="500"></div>
<div class="field"><label for="temp_max">Düse max (°C)</label><input id="temp_max" type="number" min="0" max="500"></div>
<div class="field"><label for="finish">Finish-Byte (hex)</label><input id="finish" maxlength="2" class="mono" value="FF"></div>
</div>
<div class="field"><label>Display-Palette</label><div id="palette" style="display:flex;flex-wrap:wrap;gap:4px"></div>
<div class="small muted" id="colorhint" style="margin-top:4px"></div></div>
<div class="field"><label>URL auf dem Tag (nur fürs Handy, der Drucker ignoriert sie)</label>
<label style="color:var(--text)"><input type="radio" name="url" value="elegoo" checked style="width:auto"> <code>https://www.elegoo.com</code> (verifizierte Vorlage)</label>
<label style="color:var(--text)"><input type="radio" name="url" value="spoolman" style="width:auto"> Link auf die Spule in Spoolman <span class="muted">(nicht am Drucker getestet)</span></label>
</div> </div>
<div class="banner err">In NFC Tools nie „Lock tag“ oder „Set password“ verwenden – der Tag wäre danach nicht mehr beschreibbar.</div>
</div> </div>
<h2>Befehlskette für NFC Tools</h2> <div class="stack" id="editor" style="display:none">
<div class="panel"> <div class="card stack">
<div id="tagerror"></div> <div><div class="label">Tag für</div><h2 id="edtitle" style="margin-top:2px">–</h2></div>
<textarea id="commands" rows="5" readonly></textarea> <div class="fields" style="grid-template-columns:repeat(2,minmax(0,1fr))">
<div class="row" style="margin-top:8px"> <div class="field"><label for="material">Material</label><select id="material"></select></div>
<button class="primary" onclick="copyText($('commands').value)">Kopieren</button> <div class="field"><label for="subtype">Untertyp</label><select id="subtype"></select></div>
<label style="margin:0;color:var(--text)"><input type="checkbox" id="showqr" style="width:auto"> QR-Code zeigen</label> <div class="field"><label for="temp_min">Düse min (°C)</label><input id="temp_min" type="number" min="0" max="500" inputmode="numeric"></div>
<div class="field"><label for="temp_max">Düse max (°C)</label><input id="temp_max" type="number" min="0" max="500" inputmode="numeric"></div>
</div>
<div class="field">
<span class="flabel">Tag-Farbe</span>
<div class="palette" id="palette"></div>
<div class="row" style="flex-wrap:nowrap;gap:8px;margin-top:4px">
<input id="color" maxlength="7" class="mono" aria-label="Farbe als Hex (RRGGBB)" autocapitalize="characters" spellcheck="false">
<input type="color" id="picker" aria-label="Farbe wählen">
</div>
<div class="small" id="colorhint"></div>
</div>
<details>
<summary>Erweitert</summary>
<div class="stack" style="padding-top:8px">
<div class="field" style="max-width:200px"><label for="finish">Finish-Byte (hex)</label><input id="finish" maxlength="2" class="mono" value="FF"></div>
<div class="field">
<span class="flabel">URL auf dem Tag (nur fürs Handy, der Drucker ignoriert sie)</span>
<label class="radio"><input type="radio" name="url" value="elegoo" checked><span><code>https://www.elegoo.com</code> (verifizierte Vorlage)</span></label>
<label class="radio"><input type="radio" name="url" value="spoolman"><span>Link auf die Spule in Spoolman <span class="muted">(nicht am Drucker getestet)</span></span></label>
</div>
</div>
</details>
</div> </div>
<div id="qr" style="display:none;margin-top:10px;background:#fff;padding:8px;border-radius:6px;width:max-content;max-width:100%;overflow:auto"></div>
<ol class="small muted" style="margin:14px 0 0;padding-left:20px">
<li>NFC Tools → <i>Other</i> → <i>Advanced NFC commands</i>.</li>
<li>Befehlskette einfügen, leeren NTAG213/215/216 ans Handy halten, ausführen.</li>
<li>Spule in die Canvas legen und den Scan am Display bestätigen.</li>
</ol>
</div>
<h2>Dekodier-Vorschau</h2> <div class="cmd">
<div class="panel"><dl class="kv" id="decoded"></dl></div> <div class="row" style="justify-content:space-between"><span class="label">Befehlskette für NFC Tools</span><span class="small" id="pages" style="color:var(--hero-muted)"></span></div>
<div id="tagerror"></div>
<textarea id="commands" rows="4" readonly aria-label="Befehlskette"></textarea>
<div class="acts">
<button class="primary" onclick="copyText($('commands').value)">Kopieren</button>
<button class="ghost" id="qrbtn" aria-pressed="false" onclick="toggleQR()">QR-Code</button>
</div>
<div id="qr" class="qr" style="display:none"></div>
<div class="check" id="check"></div>
</div>
<div class="card stack">
<details>
<summary>Dekodier-Vorschau</summary>
<dl class="kv" id="decoded" style="padding-top:8px"></dl>
</details>
<ol class="steps">
<li>NFC Tools → <i>Other</i> → <i>Advanced NFC commands</i>.</li>
<li>Befehlskette einfügen, leeren NTAG213/215/216 ans Handy halten, ausführen.</li>
<li>Spule in die Canvas legen und den Scan am Display bestätigen.</li>
</ol>
</div>
</div>
</div> </div>
{% endblock %} {% endblock %}
{% block script %} {% block script %}
<script> <script>
let META = null, SPOOLS = [], current = null, timer = null, spoolmanBase = ''; let META = null, SPOOLS = [], current = null, timer = null;
async function init() { async function init() {
META = await api('GET', '/api/materials'); META = await api('GET', '/api/materials');
$('material').innerHTML = Object.keys(META.materials).map((m) => `<option>${m}</option>`).join(''); $('material').innerHTML = Object.keys(META.materials).map((m) => `<option>${m}</option>`).join('');
$('palette').innerHTML = META.palette.map((c) => $('palette').innerHTML = META.palette.map((c) =>
`<button type="button" title="#${c}" onclick="setColor('${c}')" style="width:26px;height:26px;padding:0;background:#${c}"></button>`).join(''); `<button type="button" aria-label="Farbe #${c}" aria-pressed="false" data-c="${c}" onclick="setColor('${c}')" style="background:#${c}"></button>`).join('');
await loadSpools(); await loadSpools();
} }
@@ -73,21 +125,24 @@ async function loadSpools() {
const r = await api('GET', '/api/spoolman/spools' + ($('archived').checked ? '?archived=true' : '')); const r = await api('GET', '/api/spoolman/spools' + ($('archived').checked ? '?archived=true' : ''));
SPOOLS = r.spools; SPOOLS = r.spools;
} catch (e) { } catch (e) {
$('spools').innerHTML = `<tr><td colspan="8"><div class="banner err" style="margin:0">${esc(e.message)}</div></td></tr>`; $('spools').innerHTML = `<div class="banner err" style="margin:12px 16px">${esc(e.message)}</div>`;
return; return;
} }
renderSpools(); renderSpools();
} }
function spoolLine(s) {
return [s.vendor, s.material].filter(Boolean).join(' ') + (s.remaining != null ? ` · ${Math.round(s.remaining)} g` : '') + (s.location ? ` · ${s.location}` : '');
}
function renderSpools() { function renderSpools() {
const q = $('filter').value.toLowerCase(); const q = $('filter').value.toLowerCase();
const rows = SPOOLS.filter((s) => !q || [s.label, s.vendor, s.material, s.name, s.location].join(' ').toLowerCase().includes(q)); const rows = SPOOLS.filter((s) => !q || [s.label, s.vendor, s.material, s.name, s.location, s.id].join(' ').toLowerCase().includes(q));
$('spools').innerHTML = rows.map((s) => `<tr> $('spools').innerHTML = rows.map((s) => `<button class="item" onclick="pick(${s.id})" aria-current="${current && current.id === s.id}">
<td>${s.id}</td><td><b>${esc(s.label)}</b></td><td>${esc(s.vendor)}</td><td>${esc(s.material)} <span class="muted small">${esc(s.name)}</span></td> <span class="dot" style="background:${s.color ? '#' + s.color : 'transparent'}"></span>
<td>${s.color ? `<span class="swatch" style="background:#${s.color}"></span> <span class="mono small">${s.color}</span>` : '–'}</td> <span class="grow"><span class="title" style="display:block">${esc(s.label)} <span class="muted small" style="font-weight:500">#${s.id}</span></span>
<td>${s.remaining != null ? Math.round(s.remaining) + ' g' : '–'}</td><td class="small">${esc(s.location)}</td> <span class="sub" style="display:block">${esc(spoolLine(s))}</span></span>${CHEV}</button>`).join('')
<td><button onclick="pick(${s.id})">Tag erstellen</button></td></tr>`).join('') || '<p class="muted" style="padding:0 16px">Keine Spulen</p>';
|| '<tr><td colspan="8" class="muted">Keine Spulen</td></tr>';
} }
function fillSubtypes(material, selected) { function fillSubtypes(material, selected) {
@@ -100,28 +155,47 @@ function setColor(c) {
c = c.replace('#', '').toUpperCase(); c = c.replace('#', '').toUpperCase();
$('color').value = c; $('color').value = c;
if (/^[0-9A-F]{6}$/.test(c)) $('picker').value = '#' + c; if (/^[0-9A-F]{6}$/.test(c)) $('picker').value = '#' + c;
markPalette();
schedule(); schedule();
} }
function markPalette() {
const c = $('color').value.replace('#', '').toUpperCase();
document.querySelectorAll('#palette button').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.c.toUpperCase() === c)));
}
function pick(id) { function pick(id) {
current = SPOOLS.find((s) => s.id === id); current = SPOOLS.find((s) => s.id === id);
const sg = current.suggest; const sg = current.suggest;
$('edtitle').textContent = `Tag für ${current.label} (Spoolman #${current.id})`; $('edtitle').textContent = `${current.label} (Spoolman #${current.id})`;
$('chosen').innerHTML = `<span class="dot" style="background:${current.color ? '#' + current.color : 'transparent'}"></span>
<span class="grow"><span class="title" style="display:block">${esc(current.label)} <span class="muted small" style="font-weight:500">#${current.id}</span></span>
<span class="sub" style="display:block">${esc(spoolLine(current))}</span></span>
<svg class="chev" viewBox="0 0 14 8" style="width:14px;height:8px;flex-basis:14px" aria-hidden="true"><path d="M1 1l6 6 6-6"/></svg>`;
$('material').value = sg.material; $('material').value = sg.material;
fillSubtypes(sg.material, sg.subtype); fillSubtypes(sg.material, sg.subtype);
$('temp_min').value = sg.temp_min; $('temp_max').value = sg.temp_max; $('temp_min').value = sg.temp_min; $('temp_max').value = sg.temp_max;
$('finish').value = 'FF'; $('finish').value = 'FF';
$('colorhint').innerHTML = current.color $('colorhint').innerHTML = !current.color ? '<span class="muted">Spoolman-Spule hat keine Farbe.</span>'
? `Spoolman-Farbe <span class="swatch" style="background:#${current.color}"></span> <span class="mono">${current.color}</span> → Vorschlag <span class="mono">${sg.color}</span>` : current.color.toUpperCase() === String(sg.color).toUpperCase()
: 'Spoolman-Spule hat keine Farbe.'; ? `<span class="muted">Spoolman-Farbe <span class="mono">#${current.color}</span> passt zur Display-Palette.</span>`
: `<span style="color:var(--warn)">Spoolman hat <span class="swatch" style="background:#${current.color}"></span> <span class="mono">#${current.color}</span>, am Display nächste Farbe <span class="mono">#${sg.color}</span>.</span>`;
setColor(sg.color); setColor(sg.color);
$('tg').classList.add('picked');
$('editor').style.display = ''; $('editor').style.display = '';
$('editor').scrollIntoView({ behavior: 'smooth' }); renderSpools();
$('editor').scrollIntoView({ behavior: 'smooth', block: 'start' });
generate(); generate();
} }
function unpick() { $('tg').classList.remove('picked'); $('filter').focus(); }
function schedule() { clearTimeout(timer); timer = setTimeout(generate, 250); } function schedule() { clearTimeout(timer); timer = setTimeout(generate, 250); }
function toggleQR() {
const on = $('qr').style.display === 'none';
$('qr').style.display = on ? '' : 'none';
$('qrbtn').setAttribute('aria-pressed', String(on));
}
async function generate() { async function generate() {
if (!current) return; if (!current) return;
const useSpoolman = document.querySelector('input[name=url]:checked').value === 'spoolman'; const useSpoolman = document.querySelector('input[name=url]:checked').value === 'spoolman';
@@ -135,8 +209,10 @@ async function generate() {
const r = await api('POST', '/api/tag', body); const r = await api('POST', '/api/tag', body);
$('tagerror').innerHTML = ''; $('tagerror').innerHTML = '';
$('commands').value = r.commands; $('commands').value = r.commands;
$('pages').textContent = r.commands.split(',').length + ' Seiten';
$('qr').innerHTML = r.qr_svg; $('qr').innerHTML = r.qr_svg;
const d = r.decoded; const d = r.decoded;
$('check').textContent = `Prüfung: Spule ${d.spool_id} · ${d.subtype_name} · #${d.color} · ${d.temp_min}–${d.temp_max} °C`;
$('decoded').innerHTML = [ $('decoded').innerHTML = [
['Spoolman-ID', `${d.spool_id} <span class="muted small">(weight ${d.weight})</span>`], ['Spoolman-ID', `${d.spool_id} <span class="muted small">(weight ${d.weight})</span>`],
['Hersteller', `${esc(d.manufacturer)} <span class="muted small">(Drucker zeigt ELEGOO)</span>`], ['Hersteller', `${esc(d.manufacturer)} <span class="muted small">(Drucker zeigt ELEGOO)</span>`],
@@ -149,7 +225,7 @@ async function generate() {
].map(([k, v]) => `<dt>${k}</dt><dd>${v}</dd>`).join(''); ].map(([k, v]) => `<dt>${k}</dt><dd>${v}</dd>`).join('');
} catch (e) { } catch (e) {
$('tagerror').innerHTML = `<div class="banner err">${esc(e.message)}</div>`; $('tagerror').innerHTML = `<div class="banner err">${esc(e.message)}</div>`;
$('commands').value = ''; $('commands').value = ''; $('check').textContent = ''; $('pages').textContent = '';
} }
} }
@@ -160,10 +236,9 @@ $('material').addEventListener('change', () => {
schedule(); schedule();
}); });
for (const id of ['subtype', 'temp_min', 'temp_max', 'finish']) $(id).addEventListener('input', schedule); for (const id of ['subtype', 'temp_min', 'temp_max', 'finish']) $(id).addEventListener('input', schedule);
$('color').addEventListener('input', () => { const c = $('color').value.replace('#', ''); if (/^[0-9a-f]{6}$/i.test(c)) $('picker').value = '#' + c; schedule(); }); $('color').addEventListener('input', () => { const c = $('color').value.replace('#', ''); if (/^[0-9a-f]{6}$/i.test(c)) $('picker').value = '#' + c; markPalette(); schedule(); });
$('picker').addEventListener('input', () => setColor($('picker').value)); $('picker').addEventListener('input', () => setColor($('picker').value));
document.querySelectorAll('input[name=url]').forEach((el) => el.addEventListener('change', schedule)); document.querySelectorAll('input[name=url]').forEach((el) => el.addEventListener('change', schedule));
$('showqr').addEventListener('change', () => { $('qr').style.display = $('showqr').checked ? '' : 'none'; });
$('filter').addEventListener('input', renderSpools); $('filter').addEventListener('input', renderSpools);
$('archived').addEventListener('change', loadSpools); $('archived').addEventListener('change', loadSpools);
init(); init();
+17
View File
@@ -14,6 +14,7 @@ import paramiko
import segno import segno
from fastapi import FastAPI, HTTPException, Request from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse, Response from fastapi.responses import HTMLResponse, JSONResponse, Response
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from . import materials, tagcodec from . import materials, tagcodec
@@ -53,6 +54,7 @@ def create_app(data_dir: Path | None = None, *, bridge: Bridge | None = None, st
app = FastAPI(title="CC2 Spoolman Bridge", lifespan=lifespan, docs_url=None, redoc_url=None, openapi_url=None) app = FastAPI(title="CC2 Spoolman Bridge", lifespan=lifespan, docs_url=None, redoc_url=None, openapi_url=None)
app.state.bridge = bridge app.state.bridge = bridge
templates = Jinja2Templates(directory=str(HERE / "templates")) templates = Jinja2Templates(directory=str(HERE / "templates"))
app.mount("/static", StaticFiles(directory=str(HERE / "static")), name="static")
user, password = env.get("BRIDGE_USER", ""), env.get("BRIDGE_PASSWORD", "") user, password = env.get("BRIDGE_USER", ""), env.get("BRIDGE_PASSWORD", "")
@@ -87,6 +89,21 @@ def create_app(data_dir: Path | None = None, *, bridge: Bridge | None = None, st
def index(request: Request): def index(request: Request):
return page(request, "status.html") return page(request, "status.html")
@app.get("/log", response_class=HTMLResponse)
def log_page(request: Request):
return page(request, "log.html")
@app.get("/manifest.webmanifest")
def manifest():
return JSONResponse({
"name": "CC2 Bridge", "short_name": "CC2 Bridge", "lang": "de",
"start_url": "/", "scope": "/", "display": "standalone",
"background_color": "#EDEFF2", "theme_color": "#14181F",
"icons": [{"src": "/static/icon-192.png", "sizes": "192x192", "type": "image/png"},
{"src": "/static/icon-512.png", "sizes": "512x512", "type": "image/png"},
{"src": "/static/icon.svg", "sizes": "any", "type": "image/svg+xml"}],
}, media_type="application/manifest+json")
@app.get("/tags", response_class=HTMLResponse) @app.get("/tags", response_class=HTMLResponse)
def tags(request: Request): def tags(request: Request):
return page(request, "tags.html") return page(request, "tags.html")
+5
View File
@@ -48,6 +48,7 @@ class FakeCC2:
self.slots[4]["printer"] = None self.slots[4]["printer"] = None
self.library: list[dict] = [] self.library: list[dict] = []
self.job = {"active": False} self.job = {"active": False}
self.print_info = {"machine": {"progress": 40}, "print": {"current_layer": 12, "total_layers": 30, "remaining": 600}}
self.log: list[dict] = [] # newest first, as CC2 Control lists it self.log: list[dict] = [] # newest first, as CC2 Control lists it
self.calls: list[tuple] = [] self.calls: list[tuple] = []
self.fail = False self.fail = False
@@ -64,6 +65,10 @@ class FakeCC2:
self._check() self._check()
return copy.deepcopy(self.spools_json()) return copy.deepcopy(self.spools_json())
def printer(self):
self._check()
return copy.deepcopy(self.print_info)
def canvas(self): def canvas(self):
self._check() self._check()
return {} return {}
+14
View File
@@ -759,3 +759,17 @@ def test_multicolour_print_books_each_roll(env):
cc2.job = {"active": False} cc2.job = {"active": False}
poll(env, 10) poll(env, 10)
assert sorted(uses(env)) == [("use", 5, 6.0), ("use", 6, 3.0)] assert sorted(uses(env)) == [("use", 5, 6.0), ("use", 6, 3.0)]
def test_job_progress_only_read_while_printing(env):
b = env["bridge"]
env["cc2"].print_info = None
poll(env)
assert b.snapshot["printers"][0]["job"] is None
env["cc2"].print_info = {"machine": {"progress": 40}, "print": {"current_layer": 12, "total_layers": 30, "remaining": 600}}
env["cc2"].job = {"active": True, "file": "/local/cube.gcode", "started": 1,
"slots": [{"mm": 0, "grams": 0, "spool": ""}, {"mm": 330, "grams": 1.234, "spool": "s0001"}]}
poll(env)
job = b.snapshot["printers"][0]["job"]
assert job["file"] == "cube.gcode" and job["grams"] == 1.23 and job["slots"] == [{"slot": 1, "grams": 1.23}]
assert job["progress"] == 40 and job["layer"] == 12 and job["layers"] == 30 and job["remaining"] == 600
+10 -1
View File
@@ -28,11 +28,20 @@ def make_client(tmp_path, env=None):
def test_pages_render(tmp_path): def test_pages_render(tmp_path):
c, _ = make_client(tmp_path) c, _ = make_client(tmp_path)
for path in ("/", "/tags", "/settings"): for path in ("/", "/log", "/tags", "/settings"):
r = c.get(path) r = c.get(path)
assert r.status_code == 200 and "CC2" in r.text assert r.status_code == 200 and "CC2" in r.text
def test_web_app_files(tmp_path):
c, _ = make_client(tmp_path)
m = c.get("/manifest.webmanifest")
assert m.status_code == 200 and m.json()["display"] == "standalone"
for icon in m.json()["icons"]:
assert c.get(icon["src"]).status_code == 200
assert c.get("/static/icon-180.png").headers["content-type"] == "image/png"
def test_writes_need_marker_header(tmp_path): def test_writes_need_marker_header(tmp_path):
c, _ = make_client(tmp_path) c, _ = make_client(tmp_path)
assert c.post("/api/settings", json={"dry_run": False}).status_code == 403 assert c.post("/api/settings", json={"dry_run": False}).status_code == 403
+7
View File
@@ -98,6 +98,13 @@ def make_cc2(host_header: str, P: dict) -> FastAPI:
return {"available": True, "enabled": P["enabled"], "slots": P["slots"], "spools": P["spools"], return {"available": True, "enabled": P["enabled"], "slots": P["slots"], "spools": P["spools"],
"job": P["job"], "log": P["log"]} "job": P["job"], "log": P["log"]}
@app.get("/api/printer")
def printer_status():
active = P["job"]["active"]
return {"connected": True, "machine": {"status": 2 if active else 1, "progress": 42 if active else 0},
"print": {"filename": P["job"].get("file", ""), "state": "printing" if active else "standby",
"current_layer": 23 if active else 0, "total_layers": 55, "remaining": 2940}}
@app.get("/api/canvas") @app.get("/api/canvas")
def canvas(): def canvas():
return {"telemetry": {"result": {"canvas_info": {"canvas_list": [{"tray_list": [ return {"telemetry": {"result": {"canvas_info": {"canvas_list": [{"tray_list": [