Files
cc2-spoolman-bridge/app/templates/settings.html
T
Jonas MenzelandClaude Opus 5.5 b37cd64909
CI / test (push) Successful in 2m35s
CI / image (push) Successful in 42s
Spoolman leads CC2 Control's copies; new spools from the bridge
- Master data of CC2 Control's copies follows Spoolman (name, brand,
  colour, diameter, density, net, tare, price; not the material, which was
  fitted to the tray).
- A loaded spool's rest in CC2 Control is set to Spoolman's via
  /api/spools/adjust once everything is booked (old copy after the roll
  printed in the other printer, weigh-in in Spoolman); the resulting
  correction is not booked back.
- Copies of spools archived or deleted in Spoolman are deleted from CC2
  Control when no tray holds them (library limit 128); an outage never
  counts as deleted.
- A roll that ran out is archived in Spoolman (setting, default on); a
  scan of an archived spool maps nothing.
- A roll scanned again with unchanged tag data and put back into its old
  slot (after printing in the other printer) is recognised.
- Tag generator: "Neue Spule" creates spools in Spoolman from an existing
  or a new filament (and vendor), labelled vendor + id, then opens the tag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014GcEk5tkfFSYD6NTF66NkC
2026-10-25 21:26:49 +01:00

259 lines
15 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% 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 %}
<div class="wrap">
<div class="card modecard">
<div style="flex:1">
<div style="font-weight:800" id="modetitle">Schreiben aktiv</div>
<div class="small muted">Aus = Dry-Run: Schreibaktionen in CC2 Control und Spoolman werden nur protokolliert.</div>
</div>
<label class="switch danger"><input type="checkbox" id="write" aria-label="Schreiben aktiv"><span></span></label>
</div>
<div class="label section-label">Drucker</div>
<div class="group">
<div id="printers"></div>
<button class="item" style="color:var(--accent);font-weight:700;min-height:48px" onclick="addPrinter()">Drucker hinzufügen</button>
</div>
<p class="small muted" style="margin:8px 4px 0">Neue oder geänderte Drucker erst speichern, dann „Verbindung testen“.</p>
<form id="settings" onsubmit="event.preventDefault(); saveSettings()">
<div class="label section-label">Spoolman &amp; Buchung</div>
<div class="group">
<div class="frow"><label for="spoolman_url">Spoolman-URL</label><input id="spoolman_url" required inputmode="url" autocapitalize="off" spellcheck="false"></div>
<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>
<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 class="frow"><label for="archive_empty">Leergelaufene Rollen in Spoolman archivieren<small>Ihre Kopie in CC2 Control wird danach gelöscht</small></label><span class="switch"><input type="checkbox" id="archive_empty"><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>
{% endblock %}
{% block script %}
<script>
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 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 printerItem(p, i) {
const hk = hostKeys[p.id], isSaved = saved.has(p.id);
const chip = !isSaved ? '<span class="chip">nicht gespeichert</span>'
: !p.enabled ? '<span class="chip">pausiert</span>'
: hk ? '<span class="chip ok">SSH ok</span>' : '<span class="chip warn">Host-Key fehlt</span>';
return `<details class="pr" ${openIdx.has(i) || !p.id ? 'open' : ''} data-i="${i}">
<summary><div class="item"><div class="printer-icon">${PRINTER_ICON}</div>
<div class="grow"><div class="title">${esc(p.name || 'Neuer Drucker')}</div><div class="sub mono">${esc(p.host || '–')}</div></div>
${chip}${CHEV}</div></summary>
<div class="prbody">
<div class="fields">${PFIELDS.map(([f, l]) => `<div class="field"><label for="p${i}${f}">${l}</label>
<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>
</details>`;
}
function renderPrinters() {
$('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', () => {
const p = printers[el.dataset.i];
p[el.dataset.f] = el.type === 'checkbox' ? el.checked : el.value;
}));
}
function addPrinter() {
printers.push({ id: '', name: '', host: '', ssh_port: 22, ssh_user: 'root', cc2_url: 'http://', enabled: true });
openIdx.add(printers.length - 1);
renderPrinters();
}
function removePrinter(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;
printers.splice(i, 1);
openIdx.clear();
renderPrinters();
toast('Zum Übernehmen speichern');
}
function showMode(dry) {
$('write').checked = !dry;
$('modetitle').textContent = dry ? 'Dry-Run aktiv' : 'Schreiben aktiv';
setMode(dry);
}
async function loadSettings() {
current = await api('GET', '/api/settings');
for (const f of FIELDS) $(f).value = current[f];
$('spoolman_fields').checked = current.spoolman_fields;
$('archive_empty').checked = current.archive_empty;
printers = current.printers.map((p) => ({ ...p }));
saved = new Set(printers.map((p) => p.id));
showMode(current.dry_run);
}
async function saveSettings() {
const body = { printers };
for (const f of FIELDS) body[f] = $(f).value;
body.spoolman_fields = $('spoolman_fields').checked;
body.archive_empty = $('archive_empty').checked;
try {
current = await api('POST', '/api/settings', body);
printers = current.printers.map((p) => ({ ...p }));
saved = new Set(printers.map((p) => p.id));
showMode(current.dry_run);
$('saved').textContent = 'Gespeichert ' + new Date().toLocaleTimeString('de-DE');
toast('Gespeichert');
await loadSSH();
} 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) {
const k = info.key;
if (!k) { $('keyinfo').innerHTML = '<span class="chip warn">Kein Schlüssel hinterlegt</span>'; return; }
if (k.error) { $('keyinfo').innerHTML = `<span class="chip err">${esc(k.error)}</span>`; return; }
$('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>
<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>
<button style="margin-top:8px" onclick="copyText($('authline').textContent)">Kopieren</button>`;
}
async function loadSSH() {
const info = await api('GET', '/api/ssh');
hostKeys = info.host_keys || {};
renderKey(info);
renderPrinters();
}
$('keyfile').addEventListener('change', async (ev) => {
const f = ev.target.files[0];
if (f) $('keytext').value = await f.text();
});
async function saveKey() {
const key = $('keytext').value.trim();
if (!key) { toast('Kein Schlüssel eingegeben'); return; }
try {
const r = await api('POST', '/api/ssh/key', { key });
$('keytext').value = ''; $('keyfile').value = '';
renderKey(r); toast('Schlüssel gespeichert');
} catch (e) { toast(e.message); }
}
async function deleteKey() {
if (!confirm('SSH-Schlüssel löschen?')) return;
await api('DELETE', '/api/ssh/key'); loadSSH();
}
async function forgetHost(i) {
const p = printers[i];
if (!confirm(`Host-Key von ${p.name} vergessen? Danach muss er beim nächsten Test neu bestätigt werden.`)) return;
await api('DELETE', `/api/printers/${p.id}/ssh/hostkey`); loadSSH();
}
async function testSSH(i) {
const p = printers[i], out = $('test' + i);
out.innerHTML = '<span class="muted">Verbinde …</span>';
let r;
try { r = await api('POST', `/api/printers/${p.id}/ssh/test`); } catch (e) { out.innerHTML = `<div class="banner err">${esc(e.message)}</div>`; return; }
if (!r.ok) { out.innerHTML = `<div class="banner err">${esc(r.error)}</div>`; return; }
const rows = r.slots.map((s) => {
const ch = s.channel || {};
const c = (ch.color || '').replace(/^0x/i, '');
return `<tr><td>Slot ${s.slot + 1}</td><td>${s.occupied ? `${/^[0-9a-f]{6}$/i.test(c) ? `<span class="swatch" style="background:#${c}"></span> ` : ''}${esc(ch.detailed_type || ch.type)} <span class="muted small">${esc(ch.manufacturer)}</span>` : '<span class="muted">leer</span>'}</td>
<td class="mono">${esc(ch.weight)}</td><td>${s.spool_id ?? '<span class="muted">–</span>'}</td></tr>`;
}).join('');
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) {
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>`;
}
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) {
const p = printers[i];
if (!confirm(`Host-Key ${fp} für ${p.name} als vertrauenswürdig speichern?`)) return;
try { await api('POST', `/api/printers/${p.id}/ssh/hostkey`, { fingerprint: fp }); await loadSSH(); testSSH(i); }
catch (e) { toast(e.message); }
}
loadSettings().then(loadSSH);
</script>
{% endblock %}