- 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
196 lines
10 KiB
HTML
196 lines
10 KiB
HTML
{% 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 %}
|