New web UI with iPhone web app
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:
1 parent
67c3b54903
commit
ae44441cfb
16 files changed
+982
-285
No files matched your search
@@ -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`,
|
||||||
|
|||||||
@@ -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 |
@@ -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
@@ -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) => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 %}
|
||||||
|
|||||||
@@ -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
@@ -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 & 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
@@ -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
@@ -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
@@ -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")
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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": [
|
||||||
|
|||||||
Reference in new issue
Block a user