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
256 lines
14 KiB
HTML
256 lines
14 KiB
HTML
{% 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 & 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 %}
|