Files
cc2-spoolman-bridge/app/templates/status.html
T
Jonas MenzelandClaude Opus 5.5 d03668e3c5 Runout sets Spoolman to zero; surface prints waiting on a spool
- A roll that ran out is set to 0 g in Spoolman (like a weigh-in) instead
  of booking CC2 Control's delta, so a mirror whose count was off cannot
  book too much or too little.
- A tray that printed while CC2 Control still asked which spool it holds
  (untagged roll) shows the waiting millimetres on the status page and
  gets one log warning after the print.

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

369 lines
22 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 – 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" 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', ''], 1: ['eingelegt', 'ok'], 2: ['fördert', 'accent'] };
let ST = null, NAMES = {}, SEL = null, SHEET = null;
try { SEL = localStorage.getItem('printer'); } catch (e) {}
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 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.';
}
if (s.cc2_question) {
const mm = s.cc2_question_mm || 0;
out.question = 'CC2 fragt: Spule ' + (QUESTION[s.cc2_question] || s.cc2_question);
out.hint = mm >= 1
? `Gedruckt ohne Spule: ${fmtNum(mm)} mm (ca. ${fmtNum(mm * Math.PI * 0.765625 * 1.24 / 1000, 1)} g). CC2 Control hält den Verbrauch, bis die Spule feststeht: Rolle taggen und scannen, dann wird er nachgebucht – oder in CC2 Control „keine Spule“ wählen.`
: 'CC2 Control fragt, welche Spule das ist. Rolle taggen und scannen oder in CC2 Control beantworten.';
out.warn = mm >= 1;
}
}
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>`;
}
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>
${i.question ? `<div><span class="chip warn">${esc(i.question)}</span></div>` : ''}
<p class="hint${i.kind === 'takeout' || i.warn ? ' 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 === 'unmanaged' && i.question) sub = i.warn ? `${fmtNum(s.cc2_question_mm)} mm ohne Spule gedruckt` : i.question;
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' || i.warn ? ' 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'
? `${name}: Zuordnung von Slot ${slot + 1} aufheben? Die Spule wird in Spoolman ins Lager gebucht.`
: `${name}: Zuordnung von Slot ${slot + 1} erneut an CC2 Control senden?`;
if (!confirm(text)) return;
try { await api('POST', `/api/printers/${pid}/slots/${slot}/${action}`); toast('Eingereiht'); setTimeout(load, 800); }
catch (e) { toast(e.message); }
}
async function enableCC2(pid) {
const name = NAMES[pid] || pid;
if (!confirm(`Spulenverwaltung in CC2 Control von ${name} einschalten?\n\nAb dann zählt CC2 Control den Verbrauch je Slot.`)) return;
try {
const r = await api('POST', `/api/printers/${pid}/cc2/enable`, { confirm: true });
toast(r.dry_run ? 'Dry-Run: nur protokolliert' : 'Wird eingeschaltet');
setTimeout(load, 1500);
} catch (e) { toast(e.message); }
}
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('');
$('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(() => { if (!document.hidden) load(); }, 3000);
document.addEventListener('visibilitychange', () => { if (!document.hidden) load(); });
</script>
{% endblock %}