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
This commit is contained in:
1 parent
67c3b54903
commit
ae44441cfb
16 files changed
+982
-285
No files matched your search
+314
-71
@@ -1,68 +1,307 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}CC2 Bridge – Status{% endblock %}
|
||||
{% block heading %}Status{% endblock %}
|
||||
{% block head %}
|
||||
<style>
|
||||
.layout { display: flex; flex-wrap: wrap; gap: 28px; align-items: flex-start; }
|
||||
.printers { flex: 999 1 640px; min-width: 0; display: flex; flex-direction: column; gap: 32px; }
|
||||
.aside { flex: 1 1 300px; min-width: 0; padding: 18px 18px 8px; }
|
||||
.phead { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
|
||||
.phead h2 { font-size: 24px; letter-spacing: -.02em; }
|
||||
.phead .chip { margin-left: auto; font-size: 13px; padding: 5px 12px; }
|
||||
.printer { display: flex; flex-direction: column; gap: 14px; }
|
||||
|
||||
.job { background: var(--hero); color: var(--hero-ink); border-radius: var(--radius); padding: 18px 20px; display: flex; flex-direction: column; gap: 12px; }
|
||||
.job .k { font-size: 12px; color: var(--hero-muted); }
|
||||
.job .v { font-family: var(--mono); font-size: 20px; }
|
||||
.job .file { font-weight: 700; font-size: 16px; overflow-wrap: anywhere; }
|
||||
.job .stats { display: flex; gap: 28px; flex-wrap: wrap; }
|
||||
.job .bar { height: 6px; border-radius: 99px; background: var(--hero-track); overflow: hidden; }
|
||||
.job .bar div { height: 6px; border-radius: 99px; background: var(--hero-accent); }
|
||||
.job .note { font-size: 13px; color: var(--hero-muted); }
|
||||
.job .label { color: var(--hero-muted); }
|
||||
|
||||
.slots { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 12px; }
|
||||
.slot { display: flex; flex-direction: column; gap: 14px; min-height: 236px; }
|
||||
.slot .sh { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.slot .sh b { font-size: 14px; font-weight: 800; }
|
||||
.spool { display: flex; gap: 14px; align-items: center; text-align: left; border: 0; background: none; padding: 0; min-height: 0;
|
||||
border-radius: 12px; font-weight: inherit; color: inherit; width: 100%; }
|
||||
.spool:hover .nm { text-decoration: underline; }
|
||||
.spool .nm { font-weight: 800; font-size: 16px; overflow-wrap: anywhere; }
|
||||
.facts { display: flex; flex-direction: column; gap: 6px; font-size: 13px; }
|
||||
.facts div { display: flex; justify-content: space-between; gap: 8px; }
|
||||
.facts span:first-child { color: var(--muted); }
|
||||
.facts span:last-child { text-align: right; }
|
||||
.slot .acts { margin-top: auto; display: flex; gap: 8px; }
|
||||
.slot .acts button { flex: 1; font-size: 13px; min-height: 36px; padding: 0 8px; }
|
||||
.emptybox { flex: 1; border: 2px dashed var(--line); border-radius: 12px; display: flex; align-items: center; justify-content: center;
|
||||
color: var(--muted); font-size: 13px; font-weight: 600; min-height: 100px; }
|
||||
.dashed { border: 3px dashed var(--faint); }
|
||||
.hint { margin: 0; font-size: 13px; color: var(--muted); }
|
||||
.hint.warn { color: var(--warn); }
|
||||
|
||||
.seg { display: grid; gap: 4px; background: var(--line); padding: 4px; border-radius: 12px; }
|
||||
.seg button { border: 0; min-height: 36px; border-radius: 9px; background: transparent; color: var(--muted); font-weight: 600; }
|
||||
.seg button[aria-selected=true] { background: var(--surface); color: var(--ink); font-weight: 700; box-shadow: 0 1px 2px rgba(0,0,0,.08); }
|
||||
.seg button::before { content: ""; width: 7px; height: 7px; border-radius: 99px; background: var(--dot, var(--faint)); }
|
||||
|
||||
.logrow { display: flex; gap: 12px; padding: 12px 0; border-top: 1px solid var(--line2); }
|
||||
.logrow .msg { font-size: 14px; overflow-wrap: anywhere; }
|
||||
.logrow .meta { font-size: 12px; color: var(--muted); }
|
||||
.lastbook { display: flex; gap: 12px; align-items: center; }
|
||||
|
||||
@media (max-width: 759px) {
|
||||
.printer:not(.sel) { display: none; }
|
||||
.printers { gap: 14px; }
|
||||
.phead { display: none; }
|
||||
.single .phead { display: flex; }
|
||||
.job { padding: 16px; }
|
||||
.job .stats { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }
|
||||
.job .v { font-size: 17px; }
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div id="banners"></div>
|
||||
<div id="printers"><div class="panel muted">Lade …</div></div>
|
||||
<p class="small muted" id="lastpoll"></p>
|
||||
<h2>Protokoll</h2>
|
||||
<div class="panel table-wrap">
|
||||
<table><thead><tr><th style="width:170px">Zeit</th><th style="width:80px"></th><th>Aktion</th></tr></thead>
|
||||
<tbody id="log"></tbody></table>
|
||||
<div id="banners" class="stack" style="margin-bottom:14px"></div>
|
||||
<div id="seg" class="seg phone-only" role="tablist" aria-label="Drucker" style="margin-bottom:14px"></div>
|
||||
<div class="layout">
|
||||
<div id="printers" class="printers"><div class="card muted">Lade …</div></div>
|
||||
<aside class="card aside desktop-only" aria-label="Protokoll">
|
||||
<div class="row" style="justify-content:space-between;margin-bottom:6px"><h2 style="font-size:17px">Protokoll</h2><a href="/log" class="small" style="font-weight:700;text-decoration:none">Alle</a></div>
|
||||
<div id="log"></div>
|
||||
</aside>
|
||||
</div>
|
||||
<dialog id="sheet" class="sheet" aria-labelledby="sheet-title"><div class="sheet-in" id="sheet-in"></div></dialog>
|
||||
{% endblock %}
|
||||
{% block script %}
|
||||
<script>
|
||||
const QUESTION = { inserted: 'eingelegt', changed: 'geändert' };
|
||||
const TRAY = { 0: ['leer', 'warn'], 1: ['eingelegt', 'ok'], 2: ['fördert', 'ok'] };
|
||||
const LEVEL = { info: ['Info', ''], warn: ['Warnung', 'warn'], error: ['Fehler', 'err'], dry: ['Dry-Run', 'dry'] };
|
||||
const TRAY = { 0: ['leer', ''], 1: ['eingelegt', 'ok'], 2: ['fördert', 'accent'] };
|
||||
let ST = null, NAMES = {}, SEL = null, SHEET = null;
|
||||
try { SEL = localStorage.getItem('printer'); } catch (e) {}
|
||||
|
||||
function colorOf(ch) {
|
||||
const c = (ch.color || '').replace(/^0x/i, '');
|
||||
return /^[0-9a-f]{6}$/i.test(c) ? c : null;
|
||||
function hex(c) { c = String(c || '').replace(/^0x|^#/i, ''); return /^[0-9a-f]{6}$/i.test(c) ? '#' + c : null; }
|
||||
|
||||
function kindOf(s) {
|
||||
if (s.mapping) return 'managed';
|
||||
if (s.takeout) return 'takeout';
|
||||
if (s.tray_status === 1 || s.tray_status === 2) return 'unmanaged';
|
||||
return 'empty';
|
||||
}
|
||||
|
||||
function slotCard(p, s) {
|
||||
const ch = s.record.channel || {};
|
||||
const tray = TRAY[s.tray_status];
|
||||
const col = colorOf(ch);
|
||||
const m = s.mapping, sm = s.spoolman, cc = s.cc2_spool;
|
||||
let tag = '<span class="muted">kein Tag</span>';
|
||||
if (s.record.occupied) {
|
||||
tag = `${col ? `<span class="swatch" style="background:#${col}"></span> ` : ''}${esc(ch.detailed_type || ch.type)}` +
|
||||
` <span class="muted small">${esc(ch.manufacturer)}, weight ${esc(ch.weight)}</span>`;
|
||||
function info(s) {
|
||||
const m = s.mapping, sm = s.spoolman, cc = s.cc2_spool, ch = s.record.channel || {};
|
||||
const kind = kindOf(s);
|
||||
const tagColor = hex(ch.color);
|
||||
const out = { kind, tray: TRAY[s.tray_status] };
|
||||
if (kind === 'managed') {
|
||||
const rest = cc ? Number(cc.remaining) : (sm && sm.remaining != null ? Number(sm.remaining) : null);
|
||||
const initial = (sm && sm.initial) || 1000;
|
||||
out.color = (sm && hex(sm.color)) || tagColor || '#9AA3B0';
|
||||
out.rest = rest; out.initial = initial;
|
||||
out.pct = rest == null ? 0 : Math.max(0, Math.min(100, rest / initial * 100));
|
||||
out.title = m.name || ('Spule ' + m.spool);
|
||||
out.sub = sm ? [sm.vendor, sm.material].filter(Boolean).join(' ') : '';
|
||||
if (s.cc2_matches) out.cc2 = ['zugewiesen', 'ok'];
|
||||
else out.cc2 = m.cc2 ? ['nicht zugewiesen', 'warn'] : ['noch nicht angelegt', 'warn'];
|
||||
if (s.cc2_question) out.question = 'CC2 fragt: Spule ' + (QUESTION[s.cc2_question] || s.cc2_question);
|
||||
} else if (kind === 'takeout') {
|
||||
out.title = s.takeout.name; out.color = tagColor || '#9AA3B0';
|
||||
out.sub = 'In CC2 Control herausgenommen';
|
||||
out.hint = `${s.takeout.name} steht in Spoolman im Lager, steckt aber noch. Was dieser Slot druckt, bucht die Bridge trotzdem auf ${s.takeout.name}, bis die Rolle entnommen wird. Wieder zuordnen: Rolle herausnehmen und neu einlegen.`;
|
||||
} else if (kind === 'unmanaged') {
|
||||
out.color = tagColor;
|
||||
out.sub = s.record.occupied ? [ch.manufacturer, ch.detailed_type || ch.type].filter(Boolean).join(' ') : 'Rolle ohne Tag-Daten';
|
||||
if (s.record.spool_id != null) {
|
||||
out.title = 'Tag ' + s.record.spool_id + ' ohne Zuordnung';
|
||||
out.hint = `Tag ${s.record.spool_id} steht noch im Slot, ist aber keiner Rolle zugeordnet. Liegt diese Rolle hier, sie herausnehmen und neu einlegen (oder in CC2 Control auswählen).`;
|
||||
} else {
|
||||
out.title = 'Ohne Spoolman-Tag';
|
||||
out.hint = 'Verbrauch wird nicht gebucht. Rolle taggen und scannen, um sie zu verwalten.';
|
||||
}
|
||||
}
|
||||
let assign = '<span class="muted">–</span>';
|
||||
if (m) {
|
||||
assign = s.cc2_matches ? '<span class="tag ok">zugewiesen</span>'
|
||||
: (m.cc2 ? '<span class="tag warn">nicht zugewiesen</span>' : '<span class="tag warn">noch nicht angelegt</span>');
|
||||
}
|
||||
if (s.cc2_question) assign += ` <span class="tag warn">CC2 fragt: Spule ${esc(QUESTION[s.cc2_question] || s.cc2_question)}</span>`;
|
||||
const orphan = !m && !s.takeout && s.record.spool_id != null && (s.tray_status === 1 || s.tray_status === 2);
|
||||
const lb = s.last_booking;
|
||||
return `<div class="panel">
|
||||
<h3>Slot ${s.slot + 1} ${tray ? `<span class="tag ${tray[1]}">${tray[0]}</span>` : ''}</h3>
|
||||
<p class="small muted" style="margin:-4px 0 8px">Ort in Spoolman: ${esc(s.location)}</p>
|
||||
<dl class="kv">
|
||||
<dt>Tag</dt><dd>${tag}</dd>
|
||||
<dt>Tag-ID</dt><dd>${s.record.spool_id ?? '<span class="muted">–</span>'}</dd>
|
||||
<dt>Spoolman</dt><dd>${m ? `${sm && sm.color ? `<span class="swatch" style="background:#${sm.color}"></span> ` : ''}<b>${esc(m.name || m.spool)}</b> <span class="muted">#${m.spool}</span>` +
|
||||
(sm ? `<br><span class="small muted">${esc(sm.vendor)} ${esc(sm.material)}, Rest ${sm.remaining != null ? Math.round(sm.remaining) + ' g' : '?'}</span>` : '') : '<span class="muted">nicht zugeordnet</span>'}</dd>
|
||||
<dt>CC2 Control</dt><dd>${cc ? esc(cc.name) + ` <span class="small muted">Rest ${Number(cc.remaining).toFixed(1)} g</span><br>` : ''}${assign}</dd>
|
||||
<dt>Buchung</dt><dd>${lb ? `${lb.grams.toFixed(2)} g <span class="small muted">${fmtTime(lb.time)}</span>` +
|
||||
(lb.details ? `<br><span class="small muted">${esc(lb.details)}</span>` : '') : '<span class="muted">–</span>'}</dd>
|
||||
</dl>
|
||||
${s.takeout ? `<p class="small" style="margin:10px 0 0;color:var(--warn)">${esc(s.takeout.name)} wurde in CC2 Control herausgenommen
|
||||
und steht in Spoolman im Lager, steckt aber noch. Was dieser Slot druckt, bucht die Bridge trotzdem auf ${esc(s.takeout.name)},
|
||||
bis die Rolle entnommen wird. Wieder zuordnen: Rolle herausnehmen und neu einlegen.</p>` : ''}
|
||||
${orphan ? `<p class="small" style="margin:10px 0 0;color:var(--warn)">Tag ${s.record.spool_id} steht noch im Slot, ist aber keiner Rolle
|
||||
zugeordnet. Liegt diese Rolle hier, sie herausnehmen und neu einlegen (oder in CC2 Control auswählen).</p>` : ''}
|
||||
${m ? `<div class="row" style="margin-top:10px">
|
||||
<button onclick="slotAction('${p.id}', ${s.slot}, 'reassign')">Zuordnung erneut senden</button>
|
||||
<button class="danger" onclick="slotAction('${p.id}', ${s.slot}, 'release')">Aufheben</button></div>` : ''}
|
||||
return out;
|
||||
}
|
||||
|
||||
function ringHtml(i, size, hole, inner) {
|
||||
const bg = i.kind === 'managed'
|
||||
? `conic-gradient(${i.color} 0 ${i.pct}%, var(--track) ${i.pct}% 100%)`
|
||||
: (i.kind === 'empty' ? 'var(--bg)' : 'var(--surface)');
|
||||
const cls = i.kind === 'unmanaged' || i.kind === 'takeout' ? ' dashed' : '';
|
||||
return `<div class="ring${cls}" style="width:${size}px;height:${size}px;background:${bg}" aria-hidden="true">
|
||||
<div style="width:${hole}px;height:${hole}px">${inner}</div></div>`;
|
||||
}
|
||||
|
||||
function stateOf(p) {
|
||||
if (!p.enabled) return ['Pausiert', ''];
|
||||
if (Object.values(p.errors || {}).some(Boolean)) return ['Fehler', 'err'];
|
||||
if (p.cc2_enabled === false) return ['Verwaltung aus', 'warn'];
|
||||
if (p.job_active) return ['Druckt', 'accent'];
|
||||
return ['Bereit', 'ok'];
|
||||
}
|
||||
|
||||
function fmtLeft(sec) {
|
||||
if (sec == null || sec < 0) return '–';
|
||||
const h = Math.floor(sec / 3600), m = Math.round((sec % 3600) / 60);
|
||||
return h ? `${h}:${String(m).padStart(2, '0')} h` : `${m} min`;
|
||||
}
|
||||
|
||||
function jobCard(p) {
|
||||
const j = p.job;
|
||||
if (!j) return '';
|
||||
const prog = j.progress != null ? Math.round(j.progress) : null;
|
||||
return `<div class="job">
|
||||
<div class="row" style="justify-content:space-between;gap:8px">
|
||||
<span class="label">Druck läuft</span>
|
||||
${j.layer ? `<span class="mono small" style="color:var(--hero-muted)">Schicht ${j.layer}/${j.layers || '?'}</span>` : ''}
|
||||
</div>
|
||||
<div class="file">${esc(j.file || 'Unbekannte Datei')}</div>
|
||||
${prog != null ? `<div class="bar"><div style="width:${prog}%"></div></div>` : ''}
|
||||
<div class="stats">
|
||||
<div><div class="k">Fortschritt</div><div class="v">${prog != null ? prog + '%' : '–'}</div></div>
|
||||
<div><div class="k">Restzeit</div><div class="v">${fmtLeft(j.remaining)}</div></div>
|
||||
<div><div class="k">Verbraucht</div><div class="v">${fmtNum(j.grams, 1)} g</div></div>
|
||||
</div>
|
||||
<div class="note">Wird nach Druckende auf die Spoolman-Spulen gebucht.</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
let NAMES = {};
|
||||
function slotCard(p, s) {
|
||||
const i = info(s), lb = s.last_booking;
|
||||
const tray = i.tray ? `<span class="chip ${i.tray[1]}">${i.tray[0]}</span>` : '';
|
||||
let body;
|
||||
if (i.kind === 'managed') {
|
||||
body = `<button class="spool" onclick="openSheet('${p.id}', ${s.slot})">
|
||||
${ringHtml(i, 76, 54, `<span class="mono" style="font-size:14px;line-height:1">${i.rest == null ? '?' : Math.round(i.rest)}</span><span style="font-size:10px;color:var(--muted)">g</span>`)}
|
||||
<span style="min-width:0"><span class="nm" style="display:block">${esc(i.title)} <span class="muted small" style="font-weight:500">#${s.mapping.spool}</span></span>
|
||||
<span class="small muted" style="display:block">${esc(i.sub)}</span>
|
||||
<span class="small muted" style="display:block">von ${Math.round(i.initial)} g</span></span>
|
||||
</button>
|
||||
<div class="facts">
|
||||
<div><span>CC2 Control</span><span style="font-weight:700;color:var(--${i.cc2[1]})">${i.cc2[0]}</span></div>
|
||||
${i.question ? `<div><span></span><span class="chip warn">${esc(i.question)}</span></div>` : ''}
|
||||
<div><span>Zuletzt gebucht</span><span class="mono">${lb ? fmtNum(lb.grams, 2) + ' g · ' + fmtClock(lb.time) : '–'}</span></div>
|
||||
</div>
|
||||
<div class="acts">
|
||||
<button onclick="slotAction('${p.id}', ${s.slot}, 'reassign')">Erneut senden</button>
|
||||
<button class="danger" onclick="slotAction('${p.id}', ${s.slot}, 'release')">Aufheben</button>
|
||||
</div>`;
|
||||
} else if (i.kind === 'empty') {
|
||||
body = '<div class="emptybox">Kein Filament</div>';
|
||||
} else {
|
||||
body = `<button class="spool" onclick="openSheet('${p.id}', ${s.slot})">
|
||||
${ringHtml(i, 76, 54, i.color ? `<span class="swatch" style="width:22px;height:22px;background:${i.color}"></span>` : '')}
|
||||
<span style="min-width:0"><span class="nm" style="display:block;font-size:15px">${esc(i.title)}</span>
|
||||
<span class="small muted" style="display:block">${esc(i.sub)}</span></span>
|
||||
</button>
|
||||
<p class="hint${i.kind === 'takeout' ? ' warn' : ''}" style="margin-top:auto">${esc(i.hint)}</p>`;
|
||||
}
|
||||
return `<article class="card slot"><div class="sh"><b>Slot ${s.slot + 1}</b>${tray}</div>${body}</article>`;
|
||||
}
|
||||
|
||||
function slotRow(p, s) {
|
||||
const i = info(s);
|
||||
const inner = `<span style="font-weight:800;font-size:13px">${s.slot + 1}</span>`;
|
||||
let title = i.title, sub = i.sub, rest = '';
|
||||
if (i.kind === 'empty') { title = 'Leer'; sub = 'Kein Filament'; }
|
||||
if (i.kind === 'managed') {
|
||||
rest = i.rest == null ? '?' : Math.round(i.rest) + ' g';
|
||||
sub = [i.sub, '#' + s.mapping.spool, i.question || i.cc2[0]].filter(Boolean).join(' · ');
|
||||
}
|
||||
const tray = i.tray || ['', ''];
|
||||
const trayColor = { ok: 'var(--ok)', accent: 'var(--accent)' }[tray[1]] || 'var(--muted)';
|
||||
const tag = i.kind === 'empty' ? 'div' : 'button';
|
||||
return `<${tag} class="item" ${tag === 'button' ? `onclick="openSheet('${p.id}', ${s.slot})"` : ''}>
|
||||
${ringHtml(i, 48, 32, inner)}
|
||||
<span class="grow"><span class="title" style="display:block">${esc(title)}</span><span class="sub" style="display:block">${esc(sub)}</span></span>
|
||||
<span style="text-align:right"><span class="mono" style="display:block">${rest}</span><span style="display:block;font-size:12px;font-weight:700;color:${trayColor}">${tray[0]}</span></span>
|
||||
${tag === 'button' ? CHEV : '<span style="width:8px"></span>'}
|
||||
</${tag}>`;
|
||||
}
|
||||
|
||||
function lastBooking(p) {
|
||||
const b = p.slots.map((s) => s.last_booking && { ...s.last_booking, name: s.mapping && s.mapping.name, spool: s.mapping && s.mapping.spool })
|
||||
.filter(Boolean).sort((a, b) => b.time - a.time)[0];
|
||||
if (!b) return '';
|
||||
return `<div class="card lastbook phone-only">
|
||||
<div class="icon-badge" style="background:var(--ok-bg);color:var(--ok)">g</div>
|
||||
<div style="min-width:0"><div class="small muted">Letzte Buchung · ${fmtClock(b.time)}</div>
|
||||
<div style="font-weight:700;font-size:14px">${fmtNum(b.grams, 2)} g auf ${esc(b.name || '')}${b.spool ? ` (Spoolman ${b.spool})` : ''}</div>
|
||||
${b.details ? `<div class="small muted" style="overflow-wrap:anywhere">${esc(b.details)}</div>` : ''}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function printerSection(p) {
|
||||
const st = stateOf(p), pb = [];
|
||||
if (!p.enabled) pb.push('<div class="banner warn">Abfrage pausiert (in den Einstellungen deaktiviert).</div>');
|
||||
else if (ST.ssh_key && !p.host_key) pb.push('<div class="banner warn">Host-Key noch nicht bestätigt. <a href="/settings">Verbindung testen</a></div>');
|
||||
if (p.cc2_enabled === false) pb.push(`<div class="banner err"><span style="flex:1 1 220px">Spulenverwaltung in CC2 Control ist ausgeschaltet. Verbrauch wird nicht gezählt.</span><button class="primary" onclick="enableCC2('${p.id}')">Einschalten …</button></div>`);
|
||||
for (const v of Object.values(p.errors || {})) if (v) pb.push(`<div class="banner err">${esc(v)}</div>`);
|
||||
return `<section class="printer${p.id === SEL ? ' sel' : ''}" aria-label="${esc(p.name)}">
|
||||
<div class="phead"><h2>${esc(p.name)}</h2><span class="mono small muted host">${esc(p.host)}</span><span class="chip dot ${st[1]}">${st[0]}</span></div>
|
||||
${pb.join('')}
|
||||
${jobCard(p)}
|
||||
<div class="slots desktop-only">${p.slots.map((s) => slotCard(p, s)).join('')}</div>
|
||||
<div class="group phone-only">${p.slots.map((s) => slotRow(p, s)).join('')}</div>
|
||||
${lastBooking(p)}
|
||||
</section>`;
|
||||
}
|
||||
|
||||
function renderSeg(printers) {
|
||||
const seg = $('seg');
|
||||
if (printers.length < 2) { seg.style.display = 'none'; return; }
|
||||
seg.style.display = '';
|
||||
seg.style.gridTemplateColumns = `repeat(${printers.length}, minmax(0, 1fr))`;
|
||||
const dot = { ok: 'var(--ok)', accent: 'var(--accent)', err: 'var(--err)', warn: 'var(--warn)' };
|
||||
seg.innerHTML = printers.map((p) => `<button role="tab" aria-selected="${p.id === SEL}" style="--dot:${dot[stateOf(p)[1]] || 'var(--faint)'}"
|
||||
onclick="selectPrinter('${p.id}')">${esc(p.name)}</button>`).join('');
|
||||
}
|
||||
function selectPrinter(id) {
|
||||
SEL = id;
|
||||
try { localStorage.setItem('printer', id); } catch (e) {}
|
||||
render();
|
||||
}
|
||||
|
||||
function sheetHtml(p, s) {
|
||||
const i = info(s), m = s.mapping, sm = s.spoolman, cc = s.cc2_spool, ch = s.record.channel || {}, lb = s.last_booking;
|
||||
const rows = [];
|
||||
rows.push(['Tag', s.record.occupied ? `${hex(ch.color) ? `<span class="swatch" style="background:${hex(ch.color)}"></span> ` : ''}${esc(ch.detailed_type || ch.type)} <span class="muted small">${esc(ch.manufacturer)}</span>` : '<span class="muted">kein Tag</span>']);
|
||||
rows.push(['Tag-ID', s.record.spool_id ?? '<span class="muted">–</span>']);
|
||||
if (m) rows.push(['Spoolman', `${sm && sm.color ? `<span class="swatch" style="background:#${sm.color}"></span> ` : ''}<b>${esc(m.name || m.spool)}</b> <span class="muted">#${m.spool}</span>` +
|
||||
(sm ? `<br><span class="small muted">${esc(sm.vendor)} ${esc(sm.material)}${sm.name ? ' · ' + esc(sm.name) : ''}, Rest ${sm.remaining != null ? Math.round(sm.remaining) + ' g' : '?'}</span>` : '')]);
|
||||
rows.push(['CC2 Control', (cc ? `${esc(cc.name)} <span class="small muted">Rest ${fmtNum(cc.remaining, 1)} g</span><br>` : '') +
|
||||
(i.cc2 ? `<span class="chip ${i.cc2[1]}">${i.cc2[0]}</span>` : '<span class="muted">–</span>') + (i.question ? ` <span class="chip warn">${esc(i.question)}</span>` : '')]);
|
||||
rows.push(['Ort in Spoolman', esc(s.location)]);
|
||||
rows.push(['Buchung', lb ? `${fmtNum(lb.grams, 2)} g <span class="small muted">${fmtTime(lb.time)}</span>${lb.details ? `<br><span class="small muted">${esc(lb.details)}</span>` : ''}` : '<span class="muted">–</span>']);
|
||||
const inner = i.kind === 'managed' ? `<span class="mono" style="font-size:15px;line-height:1">${i.rest == null ? '?' : Math.round(i.rest)}</span><span style="font-size:10px;color:var(--muted)">g</span>` : '';
|
||||
return `<div class="grip"></div>
|
||||
<div class="row" style="flex-wrap:nowrap;gap:14px">
|
||||
${ringHtml(i, 64, 44, inner)}
|
||||
<div style="flex:1;min-width:0"><div class="small muted">${esc(p.name)} · Slot ${s.slot + 1}${i.tray ? ' · ' + i.tray[0] : ''}</div>
|
||||
<h2 id="sheet-title" style="overflow-wrap:anywhere">${esc(i.title || 'Leer')}</h2>${i.sub ? `<div class="small muted">${esc(i.sub)}</div>` : ''}</div>
|
||||
<button aria-label="Schließen" onclick="closeSheet()" style="min-width:40px;padding:0;border-radius:99px">✕</button>
|
||||
</div>
|
||||
${i.hint ? `<p class="hint${i.kind === 'takeout' ? ' warn' : ''}">${esc(i.hint)}</p>` : ''}
|
||||
<dl class="kv">${rows.map(([k, v]) => `<dt>${k}</dt><dd>${v}</dd>`).join('')}</dl>
|
||||
${m ? `<div class="row" style="flex-wrap:nowrap"><button style="flex:1;min-height:48px" onclick="slotAction('${p.id}', ${s.slot}, 'reassign')">Erneut senden</button>
|
||||
<button class="danger" style="flex:1;min-height:48px" onclick="slotAction('${p.id}', ${s.slot}, 'release')">Aufheben</button></div>` : ''}`;
|
||||
}
|
||||
function openSheet(pid, slot) {
|
||||
SHEET = { pid, slot };
|
||||
renderSheet();
|
||||
if (!$('sheet').open) $('sheet').showModal();
|
||||
}
|
||||
function renderSheet() {
|
||||
if (!SHEET || !ST) return;
|
||||
const p = ST.printers.find((x) => x.id === SHEET.pid);
|
||||
if (!p) { closeSheet(); return; }
|
||||
$('sheet-in').innerHTML = sheetHtml(p, p.slots[SHEET.slot]);
|
||||
}
|
||||
function closeSheet() { SHEET = null; if ($('sheet').open) $('sheet').close(); }
|
||||
$('sheet').addEventListener('close', () => { SHEET = null; });
|
||||
$('sheet').addEventListener('click', (ev) => { if (ev.target === $('sheet')) closeSheet(); });
|
||||
|
||||
async function slotAction(pid, slot, action) {
|
||||
const name = NAMES[pid] || pid;
|
||||
const text = action === 'release'
|
||||
@@ -83,33 +322,37 @@ async function enableCC2(pid) {
|
||||
} catch (e) { toast(e.message); }
|
||||
}
|
||||
|
||||
async function load() {
|
||||
let st;
|
||||
try { st = await api('GET', '/api/status'); } catch (e) { $('banners').innerHTML = `<div class="banner err">${esc(e.message)}</div>`; return; }
|
||||
setMode(st.dry_run);
|
||||
function render() {
|
||||
const st = ST, printers = st.printers || [];
|
||||
if (!printers.some((p) => p.id === SEL)) SEL = printers.length ? printers[0].id : null;
|
||||
const b = [];
|
||||
if (st.dry_run) b.push('<div class="banner dry">Dry-Run aktiv: Schreibaktionen werden nur protokolliert („würde …“). Umschalten unter Einstellungen.</div>');
|
||||
if (!st.ssh_key) b.push('<div class="banner warn">Kein SSH-Schlüssel hinterlegt. <a href="/settings">Einstellungen</a></div>');
|
||||
if (st.spoolman_error) b.push(`<div class="banner err">${esc(st.spoolman_error)}</div>`);
|
||||
$('banners').innerHTML = b.join('');
|
||||
NAMES = Object.fromEntries((st.printers || []).map((p) => [p.id, p.name]));
|
||||
if (st.printers) $('printers').innerHTML = st.printers.map((p) => {
|
||||
const pb = [];
|
||||
if (!p.enabled) pb.push('<div class="banner warn">Abfrage pausiert (in den Einstellungen deaktiviert).</div>');
|
||||
else if (st.ssh_key && !p.host_key) pb.push('<div class="banner warn">Host-Key noch nicht bestätigt. <a href="/settings">Verbindung testen</a></div>');
|
||||
if (p.cc2_enabled === false) pb.push(`<div class="banner err"><b>Spulenverwaltung in CC2 Control ist ausgeschaltet.</b> Verbrauch wird nicht gezählt. <button class="primary" onclick="enableCC2('${p.id}')">Einschalten …</button></div>`);
|
||||
if (p.job_active) pb.push('<div class="banner ok">Druck läuft – Verbrauch wird nach dem Druck gebucht.</div>');
|
||||
for (const v of Object.values(p.errors || {})) if (v) pb.push(`<div class="banner err">${esc(v)}</div>`);
|
||||
return `<h2>${esc(p.name)} <span class="small muted" style="font-weight:400">${esc(p.host)}</span></h2>${pb.join('')}
|
||||
<div class="grid">${p.slots.map((s) => slotCard(p, s)).join('')}</div>`;
|
||||
}).join('') || '<div class="panel muted">Kein Drucker eingerichtet. <a href="/settings">Einstellungen</a></div>';
|
||||
$('lastpoll').textContent = st.last_poll ? 'Letzte Abfrage: ' + fmtTime(st.last_poll) : 'Noch keine Abfrage';
|
||||
$('log').innerHTML = (st.log || []).map((e) => {
|
||||
const l = LEVEL[e.level] || [e.level, ''];
|
||||
return `<tr><td class="small muted">${fmtTime(e.time)}</td><td><span class="tag ${l[1]}">${l[0]}</span></td><td>${esc(e.msg)}</td></tr>`;
|
||||
}).join('') || '<tr><td colspan="3" class="muted">Noch keine Einträge</td></tr>';
|
||||
$('banners').style.display = b.length ? '' : 'none';
|
||||
renderSeg(printers);
|
||||
$('printers').classList.toggle('single', printers.length < 2);
|
||||
$('printers').innerHTML = printers.map(printerSection).join('')
|
||||
|| '<div class="card muted">Kein Drucker eingerichtet. <a href="/settings">Einstellungen</a></div>';
|
||||
$('log').innerHTML = (st.log || []).slice(0, 8).map((e) => {
|
||||
const [ic, bg, fg] = logIcon(e), [pn, msg] = splitPrinter(e.msg, Object.values(NAMES));
|
||||
return `<div class="logrow"><div class="icon-badge" style="background:${bg};color:${fg}">${ic}</div>
|
||||
<div style="min-width:0"><div class="msg">${esc(msg)}</div><div class="meta">${esc(pn)} · ${fmtClock(e.time)}</div></div></div>`;
|
||||
}).join('') || '<p class="muted small">Noch keine Einträge</p>';
|
||||
renderSheet();
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try { ST = await api('GET', '/api/status'); }
|
||||
catch (e) { $('banners').style.display = ''; $('banners').innerHTML = `<div class="banner err">${esc(e.message)}</div>`; return; }
|
||||
setMode(ST.dry_run);
|
||||
setPoll(ST.last_poll);
|
||||
NAMES = Object.fromEntries((ST.printers || []).map((p) => [p.id, p.name]));
|
||||
render();
|
||||
}
|
||||
load();
|
||||
setInterval(load, 3000);
|
||||
setInterval(() => { if (!document.hidden) load(); }, 3000);
|
||||
document.addEventListener('visibilitychange', () => { if (!document.hidden) load(); });
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in new issue
Block a user