'use strict'; /* ── Config from Flask ── */ const ROOT = document.getElementById('mirror-root'); const POLL_MS = parseInt(ROOT.dataset.pollMs || '2500', 10); const SENSORS = JSON.parse(ROOT.dataset.sensors || '[]'); /* ── State ── */ let lastLogCount = 0; let alertsSeen = 0; let chartData = []; // {ts, v} for temp channel (field_index 0) let firstPoll = true; /* ══════════════════════════════ CLOCK ══════════════════════════════ */ const DAYS = ['Sonntag','Montag','Dienstag','Mittwoch','Donnerstag','Freitag','Samstag']; const MONTHS = ['Januar','Februar','März','April','Mai','Juni', 'Juli','August','September','Oktober','November','Dezember']; function tickClock() { const n = new Date(); const pad = x => String(x).padStart(2, '0'); document.getElementById('clock-time').textContent = `${pad(n.getHours())}:${pad(n.getMinutes())}:${pad(n.getSeconds())}`; document.getElementById('clock-date').textContent = `${DAYS[n.getDay()]}, ${n.getDate()}. ${MONTHS[n.getMonth()]} ${n.getFullYear()}`; } setInterval(tickClock, 1000); tickClock(); /* ══════════════════════════════ CANVAS CHART ══════════════════════════════ */ const canvas = document.getElementById('chart'); const ctx = canvas.getContext('2d'); function resizeCanvas() { canvas.width = canvas.parentElement.clientWidth - 36; canvas.height = 130; } window.addEventListener('resize', () => { resizeCanvas(); drawChart(); }); resizeCanvas(); function drawChart() { const W = canvas.width, H = canvas.height; ctx.clearRect(0, 0, W, H); const pts = chartData.slice(-80).map(d => d.v); if (pts.length < 2) { ctx.fillStyle = 'rgba(228,233,245,.15)'; ctx.font = `11px 'JetBrains Mono', monospace`; ctx.fillText('Warte auf Messdaten …', 10, H / 2 + 4); return; } const mn = Math.min(...pts); const mx = Math.max(...pts); const rng = (mx - mn) || 1; const sx = W / (pts.length - 1); const yp = v => H - ((v - mn) / rng) * H * 0.80 - H * 0.10; /* grid */ ctx.strokeStyle = 'rgba(255,255,255,.04)'; ctx.lineWidth = 1; ctx.font = `9px 'JetBrains Mono', monospace`; for (let i = 0; i <= 4; i++) { const y = H - (i / 4) * H * 0.80 - H * 0.10; ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke(); ctx.fillStyle = 'rgba(228,233,245,.18)'; ctx.fillText((mn + (i / 4) * rng).toFixed(1) + '°', 4, y - 3); } /* gradient fill */ const grd = ctx.createLinearGradient(0, 0, 0, H); grd.addColorStop(0, 'rgba(59,130,246,.2)'); grd.addColorStop(1, 'rgba(59,130,246,.0)'); ctx.beginPath(); pts.forEach((v, i) => { const x = i * sx, y = yp(v); i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); }); ctx.lineTo((pts.length - 1) * sx, H); ctx.lineTo(0, H); ctx.closePath(); ctx.fillStyle = grd; ctx.fill(); /* line */ ctx.beginPath(); pts.forEach((v, i) => { const x = i * sx, y = yp(v); i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); }); ctx.strokeStyle = '#3b82f6'; ctx.lineWidth = 2; ctx.shadowColor = 'rgba(59,130,246,.5)'; ctx.shadowBlur = 10; ctx.stroke(); ctx.shadowBlur = 0; /* last dot */ const lv = pts[pts.length - 1]; const lx = (pts.length - 1) * sx; const ly = yp(lv); ctx.beginPath(); ctx.arc(lx, ly, 4, 0, Math.PI * 2); ctx.fillStyle = '#3b82f6'; ctx.shadowColor = '#3b82f6'; ctx.shadowBlur = 14; ctx.fill(); ctx.shadowBlur = 0; } /* ══════════════════════════════ SENSOR TILES ══════════════════════════════ */ function updateSensorTiles(values) { SENSORS.forEach((s, i) => { const idx = s.field_index; if (idx >= values.length) return; const v = values[idx]; const tile = document.getElementById(`sc-${i}`); const valEl= document.getElementById(`sv-${i}`); if (!tile || !valEl) return; const fmt = Number.isInteger(v) ? String(v) : v.toFixed(1); valEl.textContent = `${fmt}${s.unit ? ' ' + s.unit : ''}`; tile.querySelectorAll('.sensor-alert-badge').forEach(b => b.remove()); tile.classList.remove('breach-high', 'breach-low'); const isHigh = s.notify_on_high && s.threshold_high != null && v > s.threshold_high; const isLow = s.notify_on_low && s.threshold_low != null && v < s.threshold_low; if (isHigh) { tile.classList.add('breach-high'); const b = document.createElement('span'); b.className = 'sensor-alert-badge'; b.textContent = '↑ Überschritten'; tile.querySelector('.sensor-meta').appendChild(b); } else if (isLow) { tile.classList.add('breach-low'); const b = document.createElement('span'); b.className = 'sensor-alert-badge low'; b.textContent = '↓ Unterschritten'; tile.querySelector('.sensor-meta').appendChild(b); } }); } /* ══════════════════════════════ TABS ══════════════════════════════ */ function switchTab(name) { document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.tab === name)); document.querySelectorAll('.tab-pane').forEach(p => p.classList.toggle('active', p.id === `pane-${name}`)); if (name === 'alerts') { const b = document.getElementById('alert-badge'); b.classList.remove('visible'); b.textContent = ''; } } /* ══════════════════════════════ POLL ══════════════════════════════ */ async function poll() { try { const [dataRes, alertRes] = await Promise.all([ fetch('/api/data'), fetch('/api/alerts'), ]); const data = await dataRes.json(); const alerts = await alertRes.json(); /* Connection badge */ const badge = document.getElementById('conn-badge'); const ok = data.status.connected; badge.className = `conn-badge card ${ok ? 'ok' : 'err'}`; document.getElementById('conn-text').textContent = ok ? `${data.status.port} · ${data.status.baud} Bd` : 'Getrennt — warte …'; /* Stat cards */ document.getElementById('stat-total').textContent = data.status.total_lines; document.getElementById('stat-errors').textContent = data.status.errors; document.getElementById('stat-port').textContent = data.status.port || '—'; document.getElementById('stat-baud').textContent = data.status.baud ? `${data.status.baud} baud` : '—'; if (data.entries.length) { const latest = data.entries[data.entries.length - 1]; const disp = latest.values?.length ? latest.values[0].toFixed(2) : latest.raw.slice(0, 14); document.getElementById('stat-last').textContent = disp; document.getElementById('stat-ts').textContent = latest.ts.split('T')[1].slice(0,8); if (latest.values?.length) { updateSensorTiles(latest.values); chartData.push({ ts: latest.ts, v: latest.values[0] }); if (chartData.length > 200) chartData = chartData.slice(-200); } } /* Log — only new entries */ const newEntries = data.entries.slice(lastLogCount); lastLogCount = data.entries.length; const logPane = document.getElementById('pane-log'); if (firstPoll && newEntries.length === 0) { /* keep placeholder */ } else if (firstPoll) { logPane.innerHTML = ''; firstPoll = false; } newEntries.forEach(e => { const row = document.createElement('div'); row.className = 'log-entry'; const numStr = e.values?.map(v => v.toFixed(2)).join(' ') || ''; row.innerHTML = `${e.ts.split('T')[1].slice(0,8)}` + `${e.raw}` + (numStr ? `${numStr}` : ''); logPane.prepend(row); /* trim log DOM to 100 rows */ while (logPane.children.length > 100) { logPane.removeChild(logPane.lastChild); } }); /* Alerts */ if (alerts.length > alertsSeen) { const newAlerts = alerts.slice(alertsSeen); alertsSeen = alerts.length; const alertPane = document.getElementById('pane-alerts'); if (alertsSeen === newAlerts.length) alertPane.innerHTML = ''; newAlerts.forEach(a => { const row = document.createElement('div'); row.className = 'alert-entry'; row.innerHTML = `` + `${a.ts.split('T')[1].slice(0,8)}` + `${a.subject}`; alertPane.prepend(row); }); const ab = document.getElementById('alert-badge'); if (!document.getElementById('pane-alerts').classList.contains('active')) { ab.textContent = newAlerts.length > 9 ? '9+' : String(newAlerts.length); ab.classList.add('visible'); } } drawChart(); } catch (err) { console.warn('[Mirror] Poll error:', err); } setTimeout(poll, POLL_MS); } poll();