Loading noctua/web/pages/macros/widgets.html +13 −14 Original line number Diff line number Diff line Loading @@ -12,8 +12,8 @@ <h5 class="card-title d-flex justify-content-between align-items-center mb-3"> {{ label }} <div class="status-container text-end" id="status-area-{{ safe_id }}"> <span class="badge bg-secondary" id="badge-{{ safe_id }}">Unknown</span> <var class="d-none text-info small" id="var-{{ safe_id }}" style="font-style: normal;">null</var> <span class="badge bg-secondary" data-status="{{ get_url.strip('/') | replace('/', '-') if get_url else safe_id }}">Unknown</span> <var class="d-none text-info small" data-status="{{ get_url.strip('/') | replace('/', '-') if get_url else safe_id }}-raw" style="font-style: normal;">null</var> </div> </h5> Loading Loading @@ -109,7 +109,7 @@ {% endmacro %} {# --- WIDGET: Toggle (One or more buttons) --- #} {# --- WIDGET: Toggle --- #} {% macro widget_toggle(config) %} {% set safe_id = config.label | replace(' ', '-') | replace('?', '') | lower %} <fieldset class="row mb-3 align-items-center widget-universal" id="toggle-{{ safe_id }}"> Loading @@ -118,8 +118,8 @@ {% if config.info %} <div class="status-container ms-2"> <span class="badge bg-secondary" id="badge-{{ safe_id }}">Unknown</span> <var class="d-none text-info small" id="var-{{ safe_id }}" style="font-style: normal;">null</var> <span class="badge bg-secondary" data-status="{{ config.info.strip('/') | replace('/', '-') }}">Unknown</span> <var class="d-none text-info small" data-status="{{ config.info.strip('/') | replace('/', '-') }}-raw" style="font-style: normal;">null</var> </div> {% endif %} </div> Loading Loading @@ -155,7 +155,7 @@ {% endmacro %} {# --- WIDGET: Input (Numeric fields with one or more buttons) --- #} {# --- WIDGET: Input --- #} {% macro widget_input(config) %} {% set safe_id = config.label | replace(' ', '-') | lower %} <fieldset class="row mb-3 align-items-center widget-universal" id="input-{{ safe_id }}"> Loading @@ -170,8 +170,8 @@ {% if config.info %} <div class="status-container ms-2"> <span class="badge bg-secondary" id="badge-{{ safe_id }}">Unknown</span> <var class="d-none text-info small" id="var-{{ safe_id }}" style="font-style: normal;">null</var> <span class="badge bg-secondary" data-status="{{ config.info.strip('/') | replace('/', '-') }}">Unknown</span> <var class="d-none text-info small" data-status="{{ config.info.strip('/') | replace('/', '-') }}-raw" style="font-style: normal;">null</var> </div> {% endif %} </div> Loading Loading @@ -217,15 +217,15 @@ {% endmacro %} {# --- WIDGET: Shutter (Specialized dropdown) --- #} {# --- WIDGET: Shutter --- #} {% macro widget_shutter(get_url, move_url, extra_flags=True) %} {% set safe_id = "shutter" %} <fieldset class="row mb-3 align-items-center widget-universal" id="toggle-{{ safe_id }}"> <div class="col-md-4 d-flex justify-content-between align-items-center"> <label class="col-form-label">Shutter</label> <div class="status-container ms-2"> <span class="badge bg-secondary" id="badge-{{ safe_id }}">Unknown</span> <var class="d-none text-info small" id="var-{{ safe_id }}" style="font-style: normal;">null</var> <span class="badge bg-secondary" data-status="dome-shutter">Unknown</span> <var class="d-none text-info small" data-status="dome-shutter-raw" style="font-style: normal;">null</var> </div> </div> <div class="col-md-8"> Loading Loading @@ -263,7 +263,6 @@ {% endmacro %} {# --- WIDGET: Monitor Card --- #} {% macro widget_monitor(label, safe_id) %} <div class="col-md-6 monitor-wrapper d-none" id="container-{{ safe_id }}"> Loading Loading @@ -307,7 +306,7 @@ {% endmacro %} {# --- BLUEPRINT: Sub-Table for nested objects --- #} {# --- BLUEPRINT: Sub-Table --- #} {% macro monitor_subtable_template() %} <table class="table table-sm table-borderless m-0 p-0" style="background: transparent;"> <tbody class="sub-rows"></tbody> Loading @@ -315,7 +314,7 @@ {% endmacro %} {# --- BLUEPRINT: Sub-Row for nested objects --- #} {# --- BLUEPRINT: Sub-Row --- #} {% macro monitor_subrow_template() %} <tr class="sub-row"> <td class="text-muted p-0 pe-2 small key-cell" style="width: 35%"></td> Loading noctua/web/pages/webcam.html +8 −8 Original line number Diff line number Diff line Loading @@ -56,14 +56,14 @@ <div class="col-md-7"> <label class="form-label small text-muted">Movement (Alt/Az Delta)</label> <div class="input-group input-group-sm"> <input id="webcam-position" class="form-control text-center" type="number" value="20" style="max-width: 70px;"> <span class="input-group-text bg-dark border-secondary">°</span> <input id="webcam-position" class="form-control" type="number" value="20" style="max-width: 80px;"> <span class="input-group-text">°</span> <!-- data-mode: 0=Alt, 1=Az --> <button type="button" class="btn btn-outline-secondary btn-webcam-move" data-sign="1" data-mode="0" title="Up">↑</button> <button type="button" class="btn btn-outline-secondary btn-webcam-move" data-sign="-1" data-mode="0" title="Down">↓</button> <button type="button" class="btn btn-outline-secondary btn-webcam-move" data-sign="-1" data-mode="1" title="Left">←</button> <button type="button" class="btn btn-outline-secondary btn-webcam-move" data-sign="1" data-mode="1" title="Right">→</button> <button type="button" class="btn btn-secondary btn-webcam-move" data-sign="+1" data-mode="0" title="Up">↑</button> <button type="button" class="btn btn-secondary btn-webcam-move" data-sign="-1" data-mode="0" title="Down">↓</button> <button type="button" class="btn btn-secondary btn-webcam-move" data-sign="-1" data-mode="1" title="Left">←</button> <button type="button" class="btn btn-secondary btn-webcam-move" data-sign="+1" data-mode="1" title="Right">→</button> </div> </div> Loading @@ -89,6 +89,6 @@ {% block scripts %} <script src="{{ url_for('web.static', filename='js/actions.js') }}"></script> <!-- <script src="{{ url_for('web.static', filename='js/status-view.js') }}"></script> --> <script src="{{ url_for('web.static', filename='js/webcam.js') }}"></script> <script src="{{ url_for('web.static', filename='js/status-stream.js') }}"></script> {% endblock %} noctua/web/static/img/synoptic.svg +936 −699 File changed.Preview size limit exceeded, changes collapsed. Show changes noctua/web/static/js/actions.js +101 −102 Original line number Diff line number Diff line /** * actions.js — Noctua API call engine. * * Handles all user-initiated HTTP requests (GET / PUT / POST / DELETE) * originating from `.btn-universal` buttons and the raw-view checkbox. * * Depends on: ui.js (showNotification, updateUI) */ /** * Extract the request payload from a button's data attributes. * * Priority: * 1. data-payload — literal JSON value * 2. data-inputs — comma-separated list of input element IDs whose values * are collected and sent as an array (or scalar when only * one input is listed) * 3. null — no body (GET, or action with no arguments) * * @param {HTMLElement} btn * @returns {*} Parsed payload ready for JSON.stringify, or null. */ function resolvePayload(btn) { if (btn.dataset.payload) { // actions.js // Global event handler for interactive control elements using event delegation. document.addEventListener('DOMContentLoaded', () => { // Catch click events globally on body to avoid duplicate handlers per page document.body.addEventListener('click', async (event) => { const button = event.target.closest('.btn-universal'); if (!button) return; event.preventDefault(); const method = button.dataset.method || 'PUT'; const endpoint = button.dataset.url; const safeId = button.dataset.safeId; // Temporarily disable the button to prevent multiple submissions const originalContent = button.innerHTML; button.disabled = true; button.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>'; try { return JSON.parse(btn.dataset.payload); } catch { return btn.dataset.payload; let payload = null; // Dynamically collect input values if specified if (button.dataset.inputs) { const inputIds = button.dataset.inputs.split(','); const values = inputIds.map(id => { const input = document.getElementById(id.trim()); return input ? parseFloat(input.value) || input.value : null; }); payload = values.length === 1 ? values[0] : values; } else if (button.dataset.payload) { payload = JSON.parse(button.dataset.payload); } // Append force parameter dynamically if local widget checkbox is checked const forceCheckbox = document.getElementById(`force-${safeId}`); const isForced = forceCheckbox ? forceCheckbox.checked : false; let finalUrl = `/api${endpoint}`; if (isForced) { finalUrl += '?force=true'; } if (btn.dataset.inputs) { const ids = btn.dataset.inputs.split(','); const values = ids.map(id => { const el = document.getElementById(id.trim()); if (!el) return null; const v = el.value.trim(); return (v === '' || isNaN(v)) ? v : parseFloat(v); const response = await fetch(finalUrl, { method: method, headers: { 'Content-Type': 'application/json' }, body: payload !== null ? JSON.stringify(payload) : null }); return values.length === 1 ? values[0] : values; } return null; if (!response.ok) { const errorData = await response.json().catch(() => ({})); throw new Error(errorData.error ? errorData.error.join(', ') : 'Server Error'); } const result = await response.json(); document.addEventListener('DOMContentLoaded', () => { // ── Button clicks (all CRUD methods) ──────────────────────────────────── document.addEventListener('click', async (event) => { const btn = event.target.closest('.btn-universal'); if (!btn) return; if (btn.tagName === 'A') event.preventDefault(); const method = btn.dataset.method || 'GET'; const path = btn.dataset.url; const safeId = btn.dataset.safeId; const forceCb = document.getElementById(`force-${safeId}`); const url = `/api${path}${forceCb?.checked ? '?force=true' : ''}`; const payload = resolvePayload(btn); if (window.NOCTUA_DEBUG) { console.group(`API Call: ${method} ${url}`); console.log('Path:', path); console.log('Safe ID:', safeId); if (method !== 'GET') console.log('Payload:', payload); console.groupEnd(); if (typeof showToast === 'function') { showToast(`Action executed: ${endpoint}`, 'success'); } try { const fetchOptions = { method, headers: { 'Content-Type': 'application/json' }, }; if (method !== 'GET' && payload !== null) { fetchOptions.body = JSON.stringify(payload); // Apply fast local visual feedback if payload is straightforward if (result && result.response !== undefined) { updateBadgeValue(safeId, result.response, result.raw); } const response = await fetch(url, fetchOptions); const data = await response.json(); if (window.NOCTUA_DEBUG) { console.group(`Response from: ${url}`); console.log('Status:', response.status); console.log('Data:', data); console.groupEnd(); } catch (err) { console.error('Action failed:', err); if (typeof showToast === 'function') { showToast(`Error: ${err.message}`, 'danger'); } } finally { button.disabled = false; button.innerHTML = originalContent; } }); if (!response.ok) { const errorMsg = data.error ? (Array.isArray(data.error) ? data.error.join('<br>') : data.error) : 'API Error'; showNotification(`<strong>Error on ${path}:</strong><br>${errorMsg}`, true); // Handle individual widget raw toggles locally document.body.addEventListener('change', (event) => { const rawCheckbox = event.target.closest('.raw-cb'); if (rawCheckbox) { const safeId = rawCheckbox.dataset.target; const badge = document.getElementById(`badge-${safeId}`); const rawVar = document.getElementById(`var-${safeId}`); if (badge && rawVar) { if (rawCheckbox.checked) { badge.classList.add('d-none'); rawVar.classList.remove('d-none'); } else { if (method !== 'GET') { showNotification(`Action on <strong>${path}</strong> successful.`); badge.classList.remove('d-none'); rawVar.classList.add('d-none'); } updateUI(safeId, data); } } catch (error) { if (window.NOCTUA_DEBUG) console.error('Fetch error:', error); showNotification('<strong>Network Error</strong>', true); } }); // ── Raw / Badge toggle checkboxes ──────────────────────────────────────── document.addEventListener('change', (event) => { if (!event.target.classList.contains('raw-cb')) return; function updateBadgeValue(safeId, response, raw) { const badge = document.getElementById(`badge-${safeId}`); const rawVar = document.getElementById(`var-${safeId}`); const targetId = event.target.dataset.target; const badge = document.getElementById(`badge-${targetId}`); const varEl = document.getElementById(`var-${targetId}`); if (badge) { badge.textContent = response; if (badge && varEl) { badge.classList.toggle('d-none', event.target.checked); varEl.classList.toggle('d-none', !event.target.checked); // Respect active layout selection when setting a new value const rawCheckbox = document.getElementById(`raw-${safeId}`); const isRawActive = rawCheckbox ? rawCheckbox.checked : false; if (isRawActive) { badge.classList.add('d-none'); } else { badge.classList.remove('d-none'); } } if (rawVar) { rawVar.textContent = raw !== undefined ? JSON.stringify(raw) : JSON.stringify(response); } } }); }); noctua/web/static/js/status-stream.js +84 −112 Original line number Diff line number Diff line // System modules // status-stream.js // Standard telemetry listener updating badges, widgets, and info parameters on control panels using data-status attributes. // Third-party modules document.addEventListener('DOMContentLoaded', () => { // Keep in memory previous states to enable change detection for localized badge pulses const previousState = {}; // Custom modules import { UI_CONFIG, getStatusType, formatValue } from './ui-utils.js'; document.addEventListener('noctua-telemetry', (event) => { const msg = event.detail; if (!msg || !msg.name.startsWith('all-')) return; const subsystem = msg.name.replace('all-', ''); const data = msg.data; /** * Core logic to update the DOM elements based on telemetry events. * It maps JSON keys to data-status attributes and applies unified styling. */ // Initialize state cache for the subsystem if missing if (!previousState[subsystem]) { previousState[subsystem] = {}; } /** * Update a specific DOM element with formatted telemetry data. * * Parameters * ---------- * el : HTMLElement * The target element (usually a badge or span). * value : any * The raw response value from the API. * error : any * The error field from the API. * * Returns * ------- * void */ // Find all interactive widgets declaring interest in this subsystem const elements = document.querySelectorAll(`[data-status^="${subsystem}-"]`); function updateElement(el, value, error) { elements.forEach(el => { const statusKey = el.dataset.status; const parts = statusKey.split('-'); // 1. Get the semantic status type (ON, OFF, ERROR, UNKNOWN) const statusType = getStatusType(value, error); const endpoint = parts[1]; const subProperty = parts[2]; // 2. Format the value using the unified formatter const displayValue = formatValue(value); const endpointData = data[endpoint]; if (endpointData === undefined) return; // 3. Update text content el.textContent = displayValue; let responseValue = endpointData; if (endpointData && typeof endpointData === 'object' && endpointData.response !== undefined) { responseValue = endpointData.response; } // 4. Update CSS classes if the element is a badge/pill // We remove any existing state-related background classes const colorClasses = Object.values(UI_CONFIG.COLORS).map(c => c.css); el.classList.remove(...colorClasses); // Extract the appropriate nested/raw properties based on data-status selector let finalValue = responseValue; if (subProperty) { if (subProperty === 'raw') { finalValue = endpointData.raw !== undefined ? endpointData.raw : responseValue; } else if (responseValue && typeof responseValue === 'object') { finalValue = responseValue[subProperty]; } } // Add the new class defined in UI_CONFIG const targetClass = UI_CONFIG.COLORS[statusType].css; el.classList.add(targetClass); if (finalValue === undefined || finalValue === null) { finalValue = 'N/A'; } const displayValue = typeof finalValue === 'object' ? JSON.stringify(finalValue) : finalValue; /** * Process the full telemetry payload for a subsystem. * * Parameters * ---------- * subsystem : string * The subsystem name (e.g., 'dome', 'telescope'). * payload : object * The data object containing multiple device states. * * Returns * ------- * void */ function processTelemetry(subsystem, payload) { // Iterate through each device in the subsystem payload for (const [device, deviceData] of Object.entries(payload)) { const res = deviceData.response; const err = deviceData.error; // A. Handle devices where response is a primitive value (string, number, bool) if (typeof res !== 'object' || res === null) { const selector = `[data-status="${subsystem}-${device}"]`; const elements = document.querySelectorAll(selector); elements.forEach(el => updateElement(el, res, err)); } // Trigger pulse transition on changed control page badges const prevValue = previousState[subsystem][statusKey]; const hasChanged = prevValue !== undefined && prevValue !== displayValue.toString(); // B. Handle devices where response is a dictionary (e.g., coordinates) else { for (const [key, val] of Object.entries(res)) { const selector = `[data-status="${subsystem}-${device}-${key}"]`; const elements = document.querySelectorAll(selector); elements.forEach(el => updateElement(el, val, err)); } } } } previousState[subsystem][statusKey] = displayValue.toString(); if (hasChanged) { el.classList.add('pulse-update'); setTimeout(() => el.classList.remove('pulse-update'), 50); } /** * Global listener for the custom 'noctua-telemetry' event. * Dispatched by the WebSocket client. */ el.textContent = displayValue; document.addEventListener('noctua-telemetry', (e) => { // Apply standardized color layouts if (el.classList.contains('badge')) { el.className = 'badge'; const { name, data } = e.detail; // Respect layout selection when changing visual values const rawCheckbox = document.getElementById(`raw-${statusKey.replace(`${subsystem}-`, '')}`); const isRawActive = rawCheckbox ? rawCheckbox.checked : false; // name is usually 'all-subsystem' if (name.startsWith('all-')) { const subsystem = name.replace('all-', ''); processTelemetry(subsystem, data); if (isRawActive) { el.classList.add('d-none'); } else { el.classList.remove('d-none'); } }); /** * Handle FITS image preview broadcast. */ document.addEventListener('noctua-telemetry-image', (event) => { const previewImg = document.getElementById('fits-preview-img'); if (previewImg) { previewImg.src = `data:image/png;base64,${event.detail.data}`; if (displayValue === 'On' || displayValue === 'Yes' || displayValue === 'Open') { el.classList.add('bg-success'); } else if (displayValue === 'Off' || displayValue === 'No' || displayValue === 'Closed') { el.classList.add('bg-danger'); } else { el.classList.add('bg-secondary'); } } }); }); }); Loading
noctua/web/pages/macros/widgets.html +13 −14 Original line number Diff line number Diff line Loading @@ -12,8 +12,8 @@ <h5 class="card-title d-flex justify-content-between align-items-center mb-3"> {{ label }} <div class="status-container text-end" id="status-area-{{ safe_id }}"> <span class="badge bg-secondary" id="badge-{{ safe_id }}">Unknown</span> <var class="d-none text-info small" id="var-{{ safe_id }}" style="font-style: normal;">null</var> <span class="badge bg-secondary" data-status="{{ get_url.strip('/') | replace('/', '-') if get_url else safe_id }}">Unknown</span> <var class="d-none text-info small" data-status="{{ get_url.strip('/') | replace('/', '-') if get_url else safe_id }}-raw" style="font-style: normal;">null</var> </div> </h5> Loading Loading @@ -109,7 +109,7 @@ {% endmacro %} {# --- WIDGET: Toggle (One or more buttons) --- #} {# --- WIDGET: Toggle --- #} {% macro widget_toggle(config) %} {% set safe_id = config.label | replace(' ', '-') | replace('?', '') | lower %} <fieldset class="row mb-3 align-items-center widget-universal" id="toggle-{{ safe_id }}"> Loading @@ -118,8 +118,8 @@ {% if config.info %} <div class="status-container ms-2"> <span class="badge bg-secondary" id="badge-{{ safe_id }}">Unknown</span> <var class="d-none text-info small" id="var-{{ safe_id }}" style="font-style: normal;">null</var> <span class="badge bg-secondary" data-status="{{ config.info.strip('/') | replace('/', '-') }}">Unknown</span> <var class="d-none text-info small" data-status="{{ config.info.strip('/') | replace('/', '-') }}-raw" style="font-style: normal;">null</var> </div> {% endif %} </div> Loading Loading @@ -155,7 +155,7 @@ {% endmacro %} {# --- WIDGET: Input (Numeric fields with one or more buttons) --- #} {# --- WIDGET: Input --- #} {% macro widget_input(config) %} {% set safe_id = config.label | replace(' ', '-') | lower %} <fieldset class="row mb-3 align-items-center widget-universal" id="input-{{ safe_id }}"> Loading @@ -170,8 +170,8 @@ {% if config.info %} <div class="status-container ms-2"> <span class="badge bg-secondary" id="badge-{{ safe_id }}">Unknown</span> <var class="d-none text-info small" id="var-{{ safe_id }}" style="font-style: normal;">null</var> <span class="badge bg-secondary" data-status="{{ config.info.strip('/') | replace('/', '-') }}">Unknown</span> <var class="d-none text-info small" data-status="{{ config.info.strip('/') | replace('/', '-') }}-raw" style="font-style: normal;">null</var> </div> {% endif %} </div> Loading Loading @@ -217,15 +217,15 @@ {% endmacro %} {# --- WIDGET: Shutter (Specialized dropdown) --- #} {# --- WIDGET: Shutter --- #} {% macro widget_shutter(get_url, move_url, extra_flags=True) %} {% set safe_id = "shutter" %} <fieldset class="row mb-3 align-items-center widget-universal" id="toggle-{{ safe_id }}"> <div class="col-md-4 d-flex justify-content-between align-items-center"> <label class="col-form-label">Shutter</label> <div class="status-container ms-2"> <span class="badge bg-secondary" id="badge-{{ safe_id }}">Unknown</span> <var class="d-none text-info small" id="var-{{ safe_id }}" style="font-style: normal;">null</var> <span class="badge bg-secondary" data-status="dome-shutter">Unknown</span> <var class="d-none text-info small" data-status="dome-shutter-raw" style="font-style: normal;">null</var> </div> </div> <div class="col-md-8"> Loading Loading @@ -263,7 +263,6 @@ {% endmacro %} {# --- WIDGET: Monitor Card --- #} {% macro widget_monitor(label, safe_id) %} <div class="col-md-6 monitor-wrapper d-none" id="container-{{ safe_id }}"> Loading Loading @@ -307,7 +306,7 @@ {% endmacro %} {# --- BLUEPRINT: Sub-Table for nested objects --- #} {# --- BLUEPRINT: Sub-Table --- #} {% macro monitor_subtable_template() %} <table class="table table-sm table-borderless m-0 p-0" style="background: transparent;"> <tbody class="sub-rows"></tbody> Loading @@ -315,7 +314,7 @@ {% endmacro %} {# --- BLUEPRINT: Sub-Row for nested objects --- #} {# --- BLUEPRINT: Sub-Row --- #} {% macro monitor_subrow_template() %} <tr class="sub-row"> <td class="text-muted p-0 pe-2 small key-cell" style="width: 35%"></td> Loading
noctua/web/pages/webcam.html +8 −8 Original line number Diff line number Diff line Loading @@ -56,14 +56,14 @@ <div class="col-md-7"> <label class="form-label small text-muted">Movement (Alt/Az Delta)</label> <div class="input-group input-group-sm"> <input id="webcam-position" class="form-control text-center" type="number" value="20" style="max-width: 70px;"> <span class="input-group-text bg-dark border-secondary">°</span> <input id="webcam-position" class="form-control" type="number" value="20" style="max-width: 80px;"> <span class="input-group-text">°</span> <!-- data-mode: 0=Alt, 1=Az --> <button type="button" class="btn btn-outline-secondary btn-webcam-move" data-sign="1" data-mode="0" title="Up">↑</button> <button type="button" class="btn btn-outline-secondary btn-webcam-move" data-sign="-1" data-mode="0" title="Down">↓</button> <button type="button" class="btn btn-outline-secondary btn-webcam-move" data-sign="-1" data-mode="1" title="Left">←</button> <button type="button" class="btn btn-outline-secondary btn-webcam-move" data-sign="1" data-mode="1" title="Right">→</button> <button type="button" class="btn btn-secondary btn-webcam-move" data-sign="+1" data-mode="0" title="Up">↑</button> <button type="button" class="btn btn-secondary btn-webcam-move" data-sign="-1" data-mode="0" title="Down">↓</button> <button type="button" class="btn btn-secondary btn-webcam-move" data-sign="-1" data-mode="1" title="Left">←</button> <button type="button" class="btn btn-secondary btn-webcam-move" data-sign="+1" data-mode="1" title="Right">→</button> </div> </div> Loading @@ -89,6 +89,6 @@ {% block scripts %} <script src="{{ url_for('web.static', filename='js/actions.js') }}"></script> <!-- <script src="{{ url_for('web.static', filename='js/status-view.js') }}"></script> --> <script src="{{ url_for('web.static', filename='js/webcam.js') }}"></script> <script src="{{ url_for('web.static', filename='js/status-stream.js') }}"></script> {% endblock %}
noctua/web/static/img/synoptic.svg +936 −699 File changed.Preview size limit exceeded, changes collapsed. Show changes
noctua/web/static/js/actions.js +101 −102 Original line number Diff line number Diff line /** * actions.js — Noctua API call engine. * * Handles all user-initiated HTTP requests (GET / PUT / POST / DELETE) * originating from `.btn-universal` buttons and the raw-view checkbox. * * Depends on: ui.js (showNotification, updateUI) */ /** * Extract the request payload from a button's data attributes. * * Priority: * 1. data-payload — literal JSON value * 2. data-inputs — comma-separated list of input element IDs whose values * are collected and sent as an array (or scalar when only * one input is listed) * 3. null — no body (GET, or action with no arguments) * * @param {HTMLElement} btn * @returns {*} Parsed payload ready for JSON.stringify, or null. */ function resolvePayload(btn) { if (btn.dataset.payload) { // actions.js // Global event handler for interactive control elements using event delegation. document.addEventListener('DOMContentLoaded', () => { // Catch click events globally on body to avoid duplicate handlers per page document.body.addEventListener('click', async (event) => { const button = event.target.closest('.btn-universal'); if (!button) return; event.preventDefault(); const method = button.dataset.method || 'PUT'; const endpoint = button.dataset.url; const safeId = button.dataset.safeId; // Temporarily disable the button to prevent multiple submissions const originalContent = button.innerHTML; button.disabled = true; button.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>'; try { return JSON.parse(btn.dataset.payload); } catch { return btn.dataset.payload; let payload = null; // Dynamically collect input values if specified if (button.dataset.inputs) { const inputIds = button.dataset.inputs.split(','); const values = inputIds.map(id => { const input = document.getElementById(id.trim()); return input ? parseFloat(input.value) || input.value : null; }); payload = values.length === 1 ? values[0] : values; } else if (button.dataset.payload) { payload = JSON.parse(button.dataset.payload); } // Append force parameter dynamically if local widget checkbox is checked const forceCheckbox = document.getElementById(`force-${safeId}`); const isForced = forceCheckbox ? forceCheckbox.checked : false; let finalUrl = `/api${endpoint}`; if (isForced) { finalUrl += '?force=true'; } if (btn.dataset.inputs) { const ids = btn.dataset.inputs.split(','); const values = ids.map(id => { const el = document.getElementById(id.trim()); if (!el) return null; const v = el.value.trim(); return (v === '' || isNaN(v)) ? v : parseFloat(v); const response = await fetch(finalUrl, { method: method, headers: { 'Content-Type': 'application/json' }, body: payload !== null ? JSON.stringify(payload) : null }); return values.length === 1 ? values[0] : values; } return null; if (!response.ok) { const errorData = await response.json().catch(() => ({})); throw new Error(errorData.error ? errorData.error.join(', ') : 'Server Error'); } const result = await response.json(); document.addEventListener('DOMContentLoaded', () => { // ── Button clicks (all CRUD methods) ──────────────────────────────────── document.addEventListener('click', async (event) => { const btn = event.target.closest('.btn-universal'); if (!btn) return; if (btn.tagName === 'A') event.preventDefault(); const method = btn.dataset.method || 'GET'; const path = btn.dataset.url; const safeId = btn.dataset.safeId; const forceCb = document.getElementById(`force-${safeId}`); const url = `/api${path}${forceCb?.checked ? '?force=true' : ''}`; const payload = resolvePayload(btn); if (window.NOCTUA_DEBUG) { console.group(`API Call: ${method} ${url}`); console.log('Path:', path); console.log('Safe ID:', safeId); if (method !== 'GET') console.log('Payload:', payload); console.groupEnd(); if (typeof showToast === 'function') { showToast(`Action executed: ${endpoint}`, 'success'); } try { const fetchOptions = { method, headers: { 'Content-Type': 'application/json' }, }; if (method !== 'GET' && payload !== null) { fetchOptions.body = JSON.stringify(payload); // Apply fast local visual feedback if payload is straightforward if (result && result.response !== undefined) { updateBadgeValue(safeId, result.response, result.raw); } const response = await fetch(url, fetchOptions); const data = await response.json(); if (window.NOCTUA_DEBUG) { console.group(`Response from: ${url}`); console.log('Status:', response.status); console.log('Data:', data); console.groupEnd(); } catch (err) { console.error('Action failed:', err); if (typeof showToast === 'function') { showToast(`Error: ${err.message}`, 'danger'); } } finally { button.disabled = false; button.innerHTML = originalContent; } }); if (!response.ok) { const errorMsg = data.error ? (Array.isArray(data.error) ? data.error.join('<br>') : data.error) : 'API Error'; showNotification(`<strong>Error on ${path}:</strong><br>${errorMsg}`, true); // Handle individual widget raw toggles locally document.body.addEventListener('change', (event) => { const rawCheckbox = event.target.closest('.raw-cb'); if (rawCheckbox) { const safeId = rawCheckbox.dataset.target; const badge = document.getElementById(`badge-${safeId}`); const rawVar = document.getElementById(`var-${safeId}`); if (badge && rawVar) { if (rawCheckbox.checked) { badge.classList.add('d-none'); rawVar.classList.remove('d-none'); } else { if (method !== 'GET') { showNotification(`Action on <strong>${path}</strong> successful.`); badge.classList.remove('d-none'); rawVar.classList.add('d-none'); } updateUI(safeId, data); } } catch (error) { if (window.NOCTUA_DEBUG) console.error('Fetch error:', error); showNotification('<strong>Network Error</strong>', true); } }); // ── Raw / Badge toggle checkboxes ──────────────────────────────────────── document.addEventListener('change', (event) => { if (!event.target.classList.contains('raw-cb')) return; function updateBadgeValue(safeId, response, raw) { const badge = document.getElementById(`badge-${safeId}`); const rawVar = document.getElementById(`var-${safeId}`); const targetId = event.target.dataset.target; const badge = document.getElementById(`badge-${targetId}`); const varEl = document.getElementById(`var-${targetId}`); if (badge) { badge.textContent = response; if (badge && varEl) { badge.classList.toggle('d-none', event.target.checked); varEl.classList.toggle('d-none', !event.target.checked); // Respect active layout selection when setting a new value const rawCheckbox = document.getElementById(`raw-${safeId}`); const isRawActive = rawCheckbox ? rawCheckbox.checked : false; if (isRawActive) { badge.classList.add('d-none'); } else { badge.classList.remove('d-none'); } } if (rawVar) { rawVar.textContent = raw !== undefined ? JSON.stringify(raw) : JSON.stringify(response); } } }); });
noctua/web/static/js/status-stream.js +84 −112 Original line number Diff line number Diff line // System modules // status-stream.js // Standard telemetry listener updating badges, widgets, and info parameters on control panels using data-status attributes. // Third-party modules document.addEventListener('DOMContentLoaded', () => { // Keep in memory previous states to enable change detection for localized badge pulses const previousState = {}; // Custom modules import { UI_CONFIG, getStatusType, formatValue } from './ui-utils.js'; document.addEventListener('noctua-telemetry', (event) => { const msg = event.detail; if (!msg || !msg.name.startsWith('all-')) return; const subsystem = msg.name.replace('all-', ''); const data = msg.data; /** * Core logic to update the DOM elements based on telemetry events. * It maps JSON keys to data-status attributes and applies unified styling. */ // Initialize state cache for the subsystem if missing if (!previousState[subsystem]) { previousState[subsystem] = {}; } /** * Update a specific DOM element with formatted telemetry data. * * Parameters * ---------- * el : HTMLElement * The target element (usually a badge or span). * value : any * The raw response value from the API. * error : any * The error field from the API. * * Returns * ------- * void */ // Find all interactive widgets declaring interest in this subsystem const elements = document.querySelectorAll(`[data-status^="${subsystem}-"]`); function updateElement(el, value, error) { elements.forEach(el => { const statusKey = el.dataset.status; const parts = statusKey.split('-'); // 1. Get the semantic status type (ON, OFF, ERROR, UNKNOWN) const statusType = getStatusType(value, error); const endpoint = parts[1]; const subProperty = parts[2]; // 2. Format the value using the unified formatter const displayValue = formatValue(value); const endpointData = data[endpoint]; if (endpointData === undefined) return; // 3. Update text content el.textContent = displayValue; let responseValue = endpointData; if (endpointData && typeof endpointData === 'object' && endpointData.response !== undefined) { responseValue = endpointData.response; } // 4. Update CSS classes if the element is a badge/pill // We remove any existing state-related background classes const colorClasses = Object.values(UI_CONFIG.COLORS).map(c => c.css); el.classList.remove(...colorClasses); // Extract the appropriate nested/raw properties based on data-status selector let finalValue = responseValue; if (subProperty) { if (subProperty === 'raw') { finalValue = endpointData.raw !== undefined ? endpointData.raw : responseValue; } else if (responseValue && typeof responseValue === 'object') { finalValue = responseValue[subProperty]; } } // Add the new class defined in UI_CONFIG const targetClass = UI_CONFIG.COLORS[statusType].css; el.classList.add(targetClass); if (finalValue === undefined || finalValue === null) { finalValue = 'N/A'; } const displayValue = typeof finalValue === 'object' ? JSON.stringify(finalValue) : finalValue; /** * Process the full telemetry payload for a subsystem. * * Parameters * ---------- * subsystem : string * The subsystem name (e.g., 'dome', 'telescope'). * payload : object * The data object containing multiple device states. * * Returns * ------- * void */ function processTelemetry(subsystem, payload) { // Iterate through each device in the subsystem payload for (const [device, deviceData] of Object.entries(payload)) { const res = deviceData.response; const err = deviceData.error; // A. Handle devices where response is a primitive value (string, number, bool) if (typeof res !== 'object' || res === null) { const selector = `[data-status="${subsystem}-${device}"]`; const elements = document.querySelectorAll(selector); elements.forEach(el => updateElement(el, res, err)); } // Trigger pulse transition on changed control page badges const prevValue = previousState[subsystem][statusKey]; const hasChanged = prevValue !== undefined && prevValue !== displayValue.toString(); // B. Handle devices where response is a dictionary (e.g., coordinates) else { for (const [key, val] of Object.entries(res)) { const selector = `[data-status="${subsystem}-${device}-${key}"]`; const elements = document.querySelectorAll(selector); elements.forEach(el => updateElement(el, val, err)); } } } } previousState[subsystem][statusKey] = displayValue.toString(); if (hasChanged) { el.classList.add('pulse-update'); setTimeout(() => el.classList.remove('pulse-update'), 50); } /** * Global listener for the custom 'noctua-telemetry' event. * Dispatched by the WebSocket client. */ el.textContent = displayValue; document.addEventListener('noctua-telemetry', (e) => { // Apply standardized color layouts if (el.classList.contains('badge')) { el.className = 'badge'; const { name, data } = e.detail; // Respect layout selection when changing visual values const rawCheckbox = document.getElementById(`raw-${statusKey.replace(`${subsystem}-`, '')}`); const isRawActive = rawCheckbox ? rawCheckbox.checked : false; // name is usually 'all-subsystem' if (name.startsWith('all-')) { const subsystem = name.replace('all-', ''); processTelemetry(subsystem, data); if (isRawActive) { el.classList.add('d-none'); } else { el.classList.remove('d-none'); } }); /** * Handle FITS image preview broadcast. */ document.addEventListener('noctua-telemetry-image', (event) => { const previewImg = document.getElementById('fits-preview-img'); if (previewImg) { previewImg.src = `data:image/png;base64,${event.detail.data}`; if (displayValue === 'On' || displayValue === 'Yes' || displayValue === 'Open') { el.classList.add('bg-success'); } else if (displayValue === 'Off' || displayValue === 'No' || displayValue === 'Closed') { el.classList.add('bg-danger'); } else { el.classList.add('bg-secondary'); } } }); }); });