Files
cc2-spoolman-bridge/app/templates/settings.html
T
Jonas MenzelandClaude Opus 5.5 ae44441cfb
CI / test (push) Successful in 3m44s
CI / image (push) Successful in 44s
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
2026-10-25 19:55:07 +01:00

256 lines
14 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>
<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;
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;
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 %}