From 2a72972b4f4391897b6a01e7e9fd17b30a60535f Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Tue, 21 Jul 2026 16:37:37 -0500 Subject: [PATCH] feat: implement probe renaming and settings management via API --- frontend/app.py | 45 +++++++++ frontend/static/app.js | 173 ++++++++++++++++++++++++++++++---- frontend/static/style.css | 86 +++++++++++++++++ frontend/templates/index.html | 13 ++- 4 files changed, 298 insertions(+), 19 deletions(-) diff --git a/frontend/app.py b/frontend/app.py index 9a82fa5..7fc532d 100644 --- a/frontend/app.py +++ b/frontend/app.py @@ -84,6 +84,51 @@ def api_cyd_latest(): return jsonify(payload) +def _fetch_cyd_settings(): + try: + resp = requests.get(f"{config.CYD_BASE_URL}/api/settings", timeout=10) + resp.raise_for_status() + return resp.json() + except Exception as e: + app.logger.warning(f"CYD settings fetch failed: {e}") + return None + + +@app.route("/api/probes", methods=["GET", "PUT"]) +def probes(): + if request.method == "GET": + settings = _fetch_cyd_settings() + if settings is None: + # Fall back to the latest reading so the UI still has something to show. + latest = fetch_cyd() + names = [] + if latest: + names = [p.get("name", f"Probe {i + 1}") for i, p in enumerate(latest.get("probes", []))] + if not names: + names = [f"Probe {i + 1}" for i in range(4)] + return jsonify({"ok": True, "names": names, "source": "fallback"}), 200 + names = settings.get("probeNames", [f"Probe {i + 1}" for i in range(4)]) + return jsonify({"ok": True, "names": names, "source": "cyd"}) + + # PUT — forward to CYD's /api/settings with just the probeNames field. + data = request.get_json() or {} + names = data.get("names", []) + if not isinstance(names, list) or len(names) != 4 or not all(isinstance(n, str) for n in names): + return jsonify({"ok": False, "error": "names must be an array of 4 strings"}), 422 + try: + resp = requests.post( + f"{config.CYD_BASE_URL}/api/settings", + json={"probeNames": names}, + timeout=10, + ) + resp.raise_for_status() + except Exception as e: + app.logger.warning(f"CYD settings update failed: {e}") + return jsonify({"ok": False, "error": f"Unable to update CYD: {e}"}), 502 + + return jsonify({"ok": True, "names": names}) + + @app.route("/api/sessions", methods=["GET", "POST"]) def sessions(): if request.method == "POST": diff --git a/frontend/static/app.js b/frontend/static/app.js index a9dee35..f4afed1 100644 --- a/frontend/static/app.js +++ b/frontend/static/app.js @@ -8,10 +8,13 @@ const PRESETS = [ ]; let activeSession = null; +let viewedSession = null; +let allSessions = []; let latestReading = null; let setpoints = []; let chartInstance = null; let cardsInitialized = false; +let probeNames = [...DEFAULT_PROBE_NAMES]; async function api(path, options = {}) { const resp = await fetch(path, options); @@ -40,7 +43,7 @@ function createProbeCards() { card.innerHTML = `
- ${DEFAULT_PROBE_NAMES[i]} + ${DEFAULT_PROBE_NAMES[i]} #${i + 1}
No target set
@@ -84,9 +87,71 @@ function createProbeCards() { }); }); + // Click the probe name to rename it (writes through to the CYD). + document.querySelectorAll('.probe-card .name').forEach(el => { + el.addEventListener('click', () => beginRename(el)); + }); + cardsInitialized = true; } +async function beginRename(el) { + if (el.querySelector('input')) return; // already editing + const probe = parseInt(el.dataset.probe, 10); + const current = el.textContent; + el.innerHTML = ''; + const input = document.createElement('input'); + input.type = 'text'; + input.className = 'rename-input'; + input.value = current; + input.maxLength = 24; + el.appendChild(input); + input.focus(); + input.select(); + + let done = false; + const finish = async (commit) => { + if (done) return; + done = true; + const newName = (commit ? input.value.trim() : current) || current; + el.textContent = newName; + if (commit && newName !== current) { + await saveProbeName(probe, newName); + } + }; + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { e.preventDefault(); input.blur(); } + if (e.key === 'Escape') { e.preventDefault(); input.value = current; input.blur(); } + }); + input.addEventListener('blur', () => finish(true)); +} + +async function saveProbeName(probeNumber, name) { + const next = [...probeNames]; + next[probeNumber - 1] = name; + try { + const result = await api('/api/probes', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ names: next }), + }); + if (result.names) probeNames = result.names; + await refreshDashboard(); // pick up the new name in the latest reading + } catch (e) { + console.error('Rename failed:', e); + alert(`Failed to rename probe: ${e.message}`); + } +} + +async function loadProbeNames() { + try { + const result = await api('/api/probes'); + if (result.names) probeNames = result.names; + } catch (e) { + console.warn('Probe names fetch failed, using defaults:', e); + } +} + function updateProbeCards(reading, setpoints) { const probes = reading?.probes || []; const unit = reading?.unit || 'F'; @@ -95,15 +160,20 @@ function updateProbeCards(reading, setpoints) { const card = document.querySelector(`.probe-card[data-probe="${i + 1}"]`); if (!card) continue; - const probe = probes[i] || { id: i + 1, name: DEFAULT_PROBE_NAMES[i], temperature: null, connected: false }; + const probe = probes[i] || { id: i + 1, name: probeNames[i] || DEFAULT_PROBE_NAMES[i], temperature: null, connected: false }; const setpoint = setpoints.find(s => s.probe_number === (i + 1)); card.classList.toggle('connected', probe.connected); - card.querySelector('.name').textContent = probe.name || DEFAULT_PROBE_NAMES[i]; + // Don't overwrite the name while the user is editing it. + const nameEl = card.querySelector('.name'); + if (!nameEl.querySelector('input')) { + nameEl.textContent = probe.name || probeNames[i] || DEFAULT_PROBE_NAMES[i]; + } const targetEl = card.querySelector('.target'); if (setpoint) { - targetEl.textContent = `Target: ${setpoint.label ? setpoint.label + ' ' : ''}${setpoint.target_temp.toFixed(0)}°${unit}`; + const labelText = setpoint.label ? setpoint.label : `Probe ${i + 1}`; + targetEl.textContent = `${labelText}: ${setpoint.target_temp.toFixed(0)}°${unit}`; } else { targetEl.textContent = 'No target set'; } @@ -143,8 +213,8 @@ function updateProbeCards(reading, setpoints) { } async function saveSetpoint(probeNumber, label, targetTemp) { - if (!activeSession) return; - await api(`/api/setpoints/${activeSession.id}`, { + if (!viewedSession) return; + await api(`/api/setpoints/${viewedSession.id}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ probe_number: probeNumber, label, target_temp: targetTemp }), @@ -176,8 +246,12 @@ function renderStatusBar(reading) { } async function loadSessions() { - const sessions = await api('/api/sessions'); - activeSession = sessions.find(s => !s.ended_at) || null; + allSessions = await api('/api/sessions'); + activeSession = allSessions.find(s => !s.ended_at) || null; + // Default the viewed session to the active one, or the most recent past one. + if (!viewedSession || !allSessions.find(s => s.id === viewedSession.id)) { + viewedSession = activeSession || allSessions[0] || null; + } const activeEl = document.getElementById('active-session'); const endBtn = document.getElementById('end-session-btn'); @@ -189,17 +263,73 @@ async function loadSessions() { `; endBtn.disabled = false; } else { - activeEl.textContent = 'No active session.'; + activeEl.textContent = 'No active session. Start one above to begin recording.'; endBtn.disabled = true; } + + renderSessionList(); +} + +function renderSessionList() { + const listEl = document.getElementById('past-sessions'); + if (!listEl) return; + const past = allSessions.filter(s => s.ended_at); + if (past.length === 0) { + listEl.innerHTML = '

No past sessions yet.

'; + return; + } + listEl.innerHTML = past.map(s => { + const isViewed = viewedSession && viewedSession.id === s.id; + const started = formatTime(s.started_at); + const ended = formatTime(s.ended_at); + return ` + + `; + }).join(''); + + listEl.querySelectorAll('.session-row').forEach(btn => { + btn.addEventListener('click', () => { + const id = parseInt(btn.dataset.id, 10); + viewedSession = allSessions.find(s => s.id === id) || null; + renderSessionList(); + loadSetpoints(); + refreshChart(); + updateChartHeader(); + }); + }); +} + +function escapeHtml(s) { + return String(s).replace(/[&<>"']/g, c => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', + }[c])); +} + +function updateChartHeader() { + const headerEl = document.getElementById('chart-session-label'); + if (!headerEl) return; + if (!viewedSession) { + headerEl.textContent = 'No session'; + return; + } + if (activeSession && viewedSession.id === activeSession.id) { + headerEl.textContent = `Live — ${viewedSession.name}`; + } else { + headerEl.textContent = `Viewing past — ${viewedSession.name}`; + } } async function loadSetpoints() { - if (!activeSession) { + if (!viewedSession) { setpoints = []; + updateChartHeader(); return; } - setpoints = await api(`/api/setpoints/${activeSession.id}`); + setpoints = await api(`/api/setpoints/${viewedSession.id}`); + updateChartHeader(); } async function refreshDashboard() { @@ -221,13 +351,19 @@ async function refreshDashboard() { } async function refreshChart() { - if (!activeSession || !chartInstance) return; + if (!chartInstance) return; + if (!viewedSession) { + chartInstance.data.labels = []; + chartInstance.data.datasets = []; + chartInstance.update(); + return; + } const range = parseInt(document.getElementById('chart-range').value, 10); - const data = await api(`/api/readings/${activeSession.id}?minutes=${range}`); + const data = await api(`/api/readings/${viewedSession.id}?minutes=${range}`); const labels = data.map(r => formatTime(r.received_at)); const datasets = [1, 2, 3, 4].map((num, idx) => ({ - label: (latestReading?.probes?.[idx]?.name) || `Probe ${num}`, + label: probeNames[idx] || (latestReading?.probes?.[idx]?.name) || `Probe ${num}`, data: data.map(r => r[`probe${num}_temp`]), borderColor: ['#ff6b35', '#4caf50', '#2196f3', '#9c27b0'][idx], backgroundColor: ['rgba(255,107,53,0.1)', 'rgba(76,175,80,0.1)', 'rgba(33,150,243,0.1)', 'rgba(156,39,176,0.1)'][idx], @@ -271,7 +407,7 @@ document.getElementById('new-session-form').addEventListener('submit', async (e) e.preventDefault(); const name = document.getElementById('session-name').value; const notes = document.getElementById('session-notes').value; - await api('/api/sessions', { + const result = await api('/api/sessions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, notes }), @@ -279,8 +415,11 @@ document.getElementById('new-session-form').addEventListener('submit', async (e) document.getElementById('session-name').value = ''; document.getElementById('session-notes').value = ''; await loadSessions(); + // Switch the chart to the newly created active session. + viewedSession = allSessions.find(s => s.id === result.session_id) || activeSession; await loadSetpoints(); await refreshChart(); + renderSessionList(); }); document.getElementById('end-session-btn').addEventListener('click', async () => { @@ -291,6 +430,7 @@ document.getElementById('end-session-btn').addEventListener('click', async () => body: JSON.stringify({ end: true }), }); await loadSessions(); + // If we were viewing the just-ended session, keep viewing it (now a past session). await loadSetpoints(); await refreshChart(); }); @@ -300,6 +440,7 @@ document.getElementById('chart-range').addEventListener('change', refreshChart); async function init() { initChart(); + await loadProbeNames(); await loadSessions(); await loadSetpoints(); // First call creates the cards; subsequent calls only update values. @@ -307,7 +448,7 @@ async function init() { await refreshChart(); setInterval(refreshDashboard, 5000); - setInterval(() => { if (activeSession) refreshChart(); }, 30000); + setInterval(() => { if (viewedSession) refreshChart(); }, 30000); } init(); diff --git a/frontend/static/style.css b/frontend/static/style.css index a670a52..b441d65 100644 --- a/frontend/static/style.css +++ b/frontend/static/style.css @@ -139,6 +139,25 @@ main { .probe-card .name { font-weight: bold; font-size: 1.1rem; + cursor: text; + padding: 0 0.15rem; + border-radius: 4px; +} + +.probe-card .name:hover { + background: #2a2a2a; +} + +.probe-card .name .rename-input { + font: inherit; + font-weight: bold; + color: var(--text); + background: #2a2a2a; + border: 1px solid var(--accent); + border-radius: 4px; + padding: 0.1rem 0.3rem; + width: 10ch; + max-width: 100%; } .probe-card .target { @@ -212,6 +231,73 @@ main { cursor: pointer; } +.chart-container { + position: relative; + width: 100%; + height: 320px; +} + +.chart-container canvas { + width: 100% !important; + height: 100% !important; + display: block; +} + +.muted { color: var(--muted); } + +#chart-session-label { + font-size: 0.85rem; + font-weight: normal; + margin-left: 0.5rem; +} + +#past-sessions { + display: flex; + flex-direction: column; + gap: 0.4rem; + max-height: 280px; + overflow-y: auto; +} + +#past-sessions p { + margin: 0.5rem 0; +} + +.session-row { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.15rem; + background: #2a2a2a; + border: 1px solid #333; + border-left: 4px solid var(--muted); + color: var(--text); + text-align: left; + padding: 0.6rem 0.8rem; + border-radius: 6px; + cursor: pointer; + font: inherit; +} + +.session-row:hover { + border-left-color: var(--accent); + background: #333; +} + +.session-row.viewed { + border-left-color: var(--accent); + background: #2f2520; +} + +.session-row .session-name { + font-weight: 600; +} + +.session-row .session-times { + font-size: 0.8rem; + color: var(--muted); +} + footer { text-align: center; padding: 1rem; diff --git a/frontend/templates/index.html b/frontend/templates/index.html index 003d9c3..3f5cab0 100644 --- a/frontend/templates/index.html +++ b/frontend/templates/index.html @@ -28,7 +28,12 @@ - + + + +
+

Past Sessions

+

Loading…

@@ -38,7 +43,7 @@
-

Temperature History

+

Temperature History