Files
cc2-spoolman-bridge/app/templates/settings.html
T
Jonas MenzelandClaude Opus 5.5 a9041eb538
CI / test (push) Successful in 1m6s
CI / image (push) Successful in 43s
Multiple printers, printer/slot in Spoolman, kept in sync
- One worker per printer (name, IP, CC2 Control URL, own pinned host key);
  a spool sits in at most one slot of one printer, moves between printers
- Spoolman: location "<printer> Slot N" plus extra fields "drucker"/"slot"
  (created by the bridge), cleared and "Lager" on removal
- Full comparison every minute: the printer wins over manual edits of loaded
  spools; stale printer locations go to storage; own locations untouched
- Automatic migration of v1 settings, state and host key
- Status hint when a tag is still in a tray but unmapped (e.g. after
  "Take out" in CC2 Control)
- SSH test: connection drops during exec give a clean error, not a 500
- Mock environment with several printers; tests for all of the above

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

196 lines
10 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 content %}
<h2>Drucker</h2>
<div id="printers"></div>
<div class="row" style="margin-bottom:8px">
<button onclick="addPrinter()">Drucker hinzufügen</button>
<button class="primary" onclick="saveSettings()">Speichern</button>
<span id="saved1" class="small muted"></span>
</div>
<p class="small muted">Neue oder geänderte Drucker erst speichern, dann „Verbindung testen“.</p>
<h2>Allgemein</h2>
<form id="settings" class="panel" onsubmit="event.preventDefault(); saveSettings()">
<div class="row">
<div class="field"><label for="spoolman_url">Spoolman-URL</label><input id="spoolman_url" required></div>
<div class="field"><label for="id_offset">ID-Offset</label><input id="id_offset" type="number" min="0" max="65000"></div>
</div>
<div class="row">
<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>
<div class="panel">
<div id="keyinfo" class="field muted">Lade …</div>
<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"></textarea>
</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>
{% 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']];
let current = {}, printers = [], hostKeys = {}, saved = new Set();
function printerCard(p, i) {
const hk = hostKeys[p.id];
const isSaved = saved.has(p.id);
return `<div class="panel" style="margin-bottom:12px" id="pc${i}">
<div class="row">${PFIELDS.map(([f, l]) => `<div class="field"><label>${l}</label>
<input data-i="${i}" data-f="${f}" value="${esc(p[f])}" ${f === 'ssh_port' ? 'type="number" min="1" max="65535"' : ''}></div>`).join('')}</div>
<div class="row" style="align-items:center">
<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>
<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>
${isSaved ? `<button class="primary" onclick="testSSH(${i})">Verbindung testen</button>
<button onclick="forgetHost(${i})">Host-Key vergessen</button>` : ''}
<button class="danger" onclick="removePrinter(${i})">Entfernen</button>
</div>
<div id="test${i}" style="margin-top:10px"></div>
</div>`;
}
function renderPrinters() {
$('printers').innerHTML = printers.map(printerCard).join('') || '<div class="panel muted">Kein Drucker eingerichtet.</div>';
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 });
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);
renderPrinters();
}
async function loadSettings() {
current = await api('GET', '/api/settings');
for (const f of FIELDS) $(f).value = current[f];
$('dry_run').checked = current.dry_run;
$('spoolman_fields').checked = current.spoolman_fields;
printers = current.printers.map((p) => ({ ...p }));
saved = new Set(printers.map((p) => p.id));
setMode(current.dry_run);
}
async function saveSettings() {
const body = { printers };
for (const f of FIELDS) body[f] = $(f).value;
body.dry_run = $('dry_run').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 {
current = await api('POST', '/api/settings', body);
printers = current.printers.map((p) => ({ ...p }));
saved = new Set(printers.map((p) => p.id));
setMode(current.dry_run);
const t = 'Gespeichert ' + new Date().toLocaleTimeString('de-DE');
$('saved1').textContent = t; $('saved2').textContent = t;
await loadSSH();
} catch (e) { toast(e.message); }
}
function renderKey(info) {
const k = info.key;
if (!k) { $('keyinfo').innerHTML = '<span class="tag warn">Kein Schlüssel hinterlegt</span>'; return; }
if (k.error) { $('keyinfo').innerHTML = `<span class="tag 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>
<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>
<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>`;
}
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">Neuer Host-Key: <code>${esc(r.host_key.fingerprint)}</code> (${esc(r.host_key.type)}). Vergleichen und bestätigen.
<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>`;
}
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 %}