Loading noctua/web/pages/base.html +4 −2 Original line number Diff line number Diff line Loading @@ -162,10 +162,12 @@ <!-- ── UI primitives (must come before actions / status scripts) ──────── --> <script src="{{ url_for('web.static', filename='js/ui.js') }}"></script> <!-- ── Noctua Web Components ──────────────────────────────── --> <!-- ── Unified WebSocket client ───────────────────────────────────────── --> <script src="{{ url_for('web.static', filename='js/status-stream.js') }}"></script> <script src="{{ url_for('web.static', filename='js/actions.js') }}"></script> <script src="{{ url_for('web.static', filename='js/ws-client.js') }}"></script> <!-- ── Noctua Web Components ──────────────────────────────── --> <script type="module" src="{{ url_for('web.static', filename='js/components/NoctuaWidget.js') }}"></script> {% block scripts %}{% endblock %} Loading noctua/web/pages/init.html +0 −2 Original line number Diff line number Diff line Loading @@ -213,6 +213,4 @@ {% endblock %} {% block scripts %} <script src="{{ url_for('web.static', filename='js/actions.js') }}"></script> <script src="{{ url_for('web.static', filename='js/status-stream.js') }}"></script> {% endblock %} noctua/web/static/js/actions.js +19 −3 Original line number Diff line number Diff line Loading @@ -21,16 +21,32 @@ document.addEventListener('DOMContentLoaded', () => { try { let payload = null; // Dynamically collect input values if specified // 1. Resolve payload value hierarchically (Inputs -> data-value -> data-payload) if (button.dataset.inputs) { // Dynamically collect input values if specified via comma-separated IDs 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; if (!input) return null; const parsedNum = parseFloat(input.value); // Safely check if value is a valid float, otherwise fallback to raw string (Fixes zero as string bug) return isNaN(parsedNum) ? input.value : parsedNum; }); payload = values.length === 1 ? values[0] : values; } else if (button.dataset.value !== undefined) { // Read from preferred data-value attribute try { payload = JSON.parse(button.dataset.value); } catch { payload = button.dataset.value; } } else if (button.dataset.payload) { // Legacy support for data-payload attribute (deprecated) try { payload = JSON.parse(button.dataset.payload); } catch { payload = button.dataset.payload; } } // Append force parameter dynamically if local widget checkbox is checked Loading noctua/web/static/js/components/NoctuaWidget.js +36 −185 Original line number Diff line number Diff line Loading @@ -6,7 +6,6 @@ let apiSchemaCache = null; export class NoctuaWidget extends HTMLElement { constructor() { super(); // Do not read DOM attributes here, wait for connectedCallback this.route = ''; this.method = ''; } Loading Loading @@ -49,17 +48,6 @@ export class NoctuaWidget extends HTMLElement { // Select and clone the appropriate Jinja2 HTML template this._render(meta, hasGet); // Bind click event listeners to execute actions (handles both GET and action buttons) this._setupEventListeners(); // Trigger an initial GET action automatically to load the state on boot if (hasGet) { const refreshBtn = this.querySelector('.btn-refresh'); if (refreshBtn) { this._executeAction(refreshBtn); } } } catch (error) { console.error("NoctuaWidget: Initialization failed", error); this.innerHTML = `<div class="alert alert-danger p-2 small">Error loading widget: ${error.message}</div>`; Loading @@ -82,16 +70,12 @@ export class NoctuaWidget extends HTMLElement { } /** * Standardize route paths by removing leading/trailing slashes and stripping * the optional 'api/' prefix for bulletproof matching. * Standardize route paths by removing leading/trailing slashes. */ _normalizePath(p) { // Remove leading and trailing slashes and convert to lowercase let path = p.replace(/^\/|\/$/g, '').toLowerCase(); // Strip the leading 'api/' segment if it exists in the endpoint string if (path.startsWith('api/')) { path = path.slice(4); // Remove 'api/' (4 characters) path = path.slice(4); } return path; } Loading Loading @@ -141,14 +125,14 @@ export class NoctuaWidget extends HTMLElement { // Clone the template content const clone = template.content.cloneNode(true); // Normalize route for data-* attribute assignments (e.g., "telescope-coordinates-movement-altaz") // Normalize route name (e.g., "telescope-coordinates-movement-altaz") const normalizedName = this.route.replace(/^\/|\/$/g, '').replace(/\//g, '-'); // Populate semantic elements inside the cloned markup // Populate and bind semantic elements this._populateLabelsAndUnits(clone, expects, hasGet); this._bindSemanticDataAttributes(clone, normalizedName); this._bindSemanticDataAttributes(clone, normalizedName, expects); // Clear loading indicators and append to Light DOM for easy Bootstrap styling // Clear loading indicators and append to Light DOM for easy Bootstrap and global script access this.innerHTML = ''; this.appendChild(clone); } Loading @@ -173,7 +157,6 @@ export class NoctuaWidget extends HTMLElement { badge.textContent = 'Write-Only'; badge.title = 'No telemetry available for this action'; } else { // Show manual refresh button if GET is supported if (refreshBtn) { refreshBtn.style.display = 'inline-block'; } Loading Loading @@ -213,182 +196,50 @@ export class NoctuaWidget extends HTMLElement { } /** * Map data-status, data-control, and data-parameter attributes. * Map data-status, data-control, data-parameter and standard id attributes for actions.js compatibility. */ _bindSemanticDataAttributes(clone, normalizedName) { _bindSemanticDataAttributes(clone, normalizedName, expects) { // Bind dynamic DOM IDs to inputs so actions.js can resolve them const input1 = clone.querySelector('.widget-input-1'); const input2 = clone.querySelector('.widget-input-2'); const id1 = `val1-${normalizedName}`; const id2 = `val2-${normalizedName}`; if (input1) { input1.id = id1; input1.setAttribute('data-parameter', normalizedName); } if (input2) { input2.id = id2; input2.setAttribute('data-parameter', normalizedName); } // Set websocket status listener const statusBadge = clone.querySelector('.widget-status-badge'); if (statusBadge) { statusBadge.id = `badge-${normalizedName}`; statusBadge.setAttribute('data-status', normalizedName); } // Bind control targets // Bind control targets compatibile with actions.js event delegation clone.querySelectorAll('.btn-control').forEach(btn => { btn.setAttribute('data-control', normalizedName); btn.classList.add('btn-universal'); // Add btn-universal class so actions.js intercepts clicks btn.setAttribute('data-safe-id', normalizedName); btn.setAttribute('data-url', `/${this.route.replace(/^\/|\/$/g, '')}`); // Set dynamic method ONLY if it is not pre-defined in the HTML template (like GET on the refresh button) const btnMethod = btn.getAttribute('data-method'); // If data-method is missing or empty string, fallback to default widget method if (!btnMethod || btnMethod.trim() === '') { btn.setAttribute('data-method', this.method); } }); // Bind parameter inputs clone.querySelectorAll('[class*="widget-input"]').forEach(input => { input.setAttribute('data-parameter', normalizedName); }); } /** * Bind click events on buttons to parse input parameters and trigger commands. */ _setupEventListeners() { // Handle trigger buttons (now includes both action buttons and the GET refresh button) this.querySelectorAll('.btn-control').forEach(btn => { btn.addEventListener('click', async (e) => { e.preventDefault(); await this._executeAction(btn); }); }); } /** * Extract parameters and perform the HTTP request. */ async _executeAction(btn) { const method = btn.getAttribute('data-method').toUpperCase(); const url = `/api/${this.route.replace(/^\/|\/$/g, '')}`; let payload = null; // Visual feedback during command transmission this._setLoadingState(true, method === 'GET'); if (method !== 'GET') { // 1. Resolve payload value for write operations const explicitValue = btn.getAttribute('data-value'); if (explicitValue !== null) { // Case A: Element has data-value (Preset button or toggle) try { payload = JSON.parse(explicitValue); } catch { payload = explicitValue; } } else { // Case B: Gather parameters from inputs const inputs = this.querySelectorAll(`[data-parameter]`); if (inputs.length === 1) { // Single input parameter const val = parseFloat(inputs[0].value); payload = isNaN(val) ? inputs[0].value : val; } else if (inputs.length > 1) { // Multi-parameter array payload = Array.from(inputs).map(inp => { const val = parseFloat(inp.value); return isNaN(val) ? inp.value : val; }); } } } // 2. Perform HTTP request to the Quart API try { const options = { method: method }; if (method !== 'GET' && payload !== null) { options.headers = { 'Content-Type': 'application/json' }; options.body = JSON.stringify(payload); } const response = await fetch(url, options); const result = await response.json(); if (response.ok && !result.error) { if (method === 'GET') { // Update telemetry readout with the actual GET response this._updateStatusBadge(result.response); } else { // Command succeeded: briefly flash the status badge green this._flashStatusBadge('bg-success', result.response); } } else { // Command failed: briefly flash the status badge red const errMsg = result.error ? result.error.join(', ') : 'Server Error'; this._flashStatusBadge('bg-danger', 'ERR'); console.error(`NoctuaWidget: Action failed: ${errMsg}`); } } catch (error) { this._flashStatusBadge('bg-danger', 'OFF'); console.error("NoctuaWidget: Network request failed", error); } finally { // FIX: Pass method context to correctly release GET buttons animation state this._setLoadingState(false, method === 'GET'); } } /** * Directly update the text of the local telemetry badge. */ _updateStatusBadge(value) { const badge = this.querySelector('.widget-status-badge'); if (badge) { // Check if badge is in disabled mode (do not overwrite 'Write-Only' cue) if (!badge.className.includes('text-muted')) { badge.textContent = value !== null ? value : 'N/A'; } } } /** * Flash status color as visual feedback. */ _flashStatusBadge(className, tempText) { const badge = this.querySelector('.widget-status-badge'); if (!badge) return; // Skip flashing if the badge is in disabled mode (no GET support) if (badge.className.includes('text-muted')) return; const originalClass = badge.className; // Briefly show success/error feedback color badge.className = `badge ${className} font-monospace`; badge.textContent = tempText; setTimeout(() => { // Restore only the neutral styling class, preserving the updated text value badge.className = originalClass; // Trigger a quick initial state update to fetch latest actual values const refreshBtn = this.querySelector('.btn-refresh'); if (refreshBtn) { this._executeAction(refreshBtn); } }, 1200); } /** * Disable inputs/buttons during network transit. */ _setLoadingState(isLoading, isGetOnly = false) { this.querySelectorAll('input, button').forEach(el => { // If it is a GET, only animate/disable the refresh button if (isGetOnly) { if (el.classList.contains('btn-refresh')) { if (isLoading) { el.classList.add('disabled'); el.style.transform = 'rotate(180deg)'; el.style.transition = 'transform 0.4s ease'; } else { setTimeout(() => { el.style.transform = 'none'; el.style.transition = 'none'; el.classList.remove('disabled'); }, 400); } } } else { // For write actions, disable inputs and non-refresh buttons if (!el.classList.contains('btn-refresh')) { el.disabled = isLoading; // Assign standard inputs parameter list to button dataset if (this.method !== 'GET') { if (expects.type === 'single') { btn.setAttribute('data-inputs', id1); } else if (expects.type === 'array' && expects.count === 2) { btn.setAttribute('data-inputs', `${id1}, ${id2}`); } } }); Loading pyproject.toml +1 −1 Original line number Diff line number Diff line Loading @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "noctua" version = "0.4.1" version = "0.4.2" authors = [ { name="Davide Ricci", email="davide.ricci@inaf.it" }, ] Loading Loading
noctua/web/pages/base.html +4 −2 Original line number Diff line number Diff line Loading @@ -162,10 +162,12 @@ <!-- ── UI primitives (must come before actions / status scripts) ──────── --> <script src="{{ url_for('web.static', filename='js/ui.js') }}"></script> <!-- ── Noctua Web Components ──────────────────────────────── --> <!-- ── Unified WebSocket client ───────────────────────────────────────── --> <script src="{{ url_for('web.static', filename='js/status-stream.js') }}"></script> <script src="{{ url_for('web.static', filename='js/actions.js') }}"></script> <script src="{{ url_for('web.static', filename='js/ws-client.js') }}"></script> <!-- ── Noctua Web Components ──────────────────────────────── --> <script type="module" src="{{ url_for('web.static', filename='js/components/NoctuaWidget.js') }}"></script> {% block scripts %}{% endblock %} Loading
noctua/web/pages/init.html +0 −2 Original line number Diff line number Diff line Loading @@ -213,6 +213,4 @@ {% endblock %} {% block scripts %} <script src="{{ url_for('web.static', filename='js/actions.js') }}"></script> <script src="{{ url_for('web.static', filename='js/status-stream.js') }}"></script> {% endblock %}
noctua/web/static/js/actions.js +19 −3 Original line number Diff line number Diff line Loading @@ -21,16 +21,32 @@ document.addEventListener('DOMContentLoaded', () => { try { let payload = null; // Dynamically collect input values if specified // 1. Resolve payload value hierarchically (Inputs -> data-value -> data-payload) if (button.dataset.inputs) { // Dynamically collect input values if specified via comma-separated IDs 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; if (!input) return null; const parsedNum = parseFloat(input.value); // Safely check if value is a valid float, otherwise fallback to raw string (Fixes zero as string bug) return isNaN(parsedNum) ? input.value : parsedNum; }); payload = values.length === 1 ? values[0] : values; } else if (button.dataset.value !== undefined) { // Read from preferred data-value attribute try { payload = JSON.parse(button.dataset.value); } catch { payload = button.dataset.value; } } else if (button.dataset.payload) { // Legacy support for data-payload attribute (deprecated) try { payload = JSON.parse(button.dataset.payload); } catch { payload = button.dataset.payload; } } // Append force parameter dynamically if local widget checkbox is checked Loading
noctua/web/static/js/components/NoctuaWidget.js +36 −185 Original line number Diff line number Diff line Loading @@ -6,7 +6,6 @@ let apiSchemaCache = null; export class NoctuaWidget extends HTMLElement { constructor() { super(); // Do not read DOM attributes here, wait for connectedCallback this.route = ''; this.method = ''; } Loading Loading @@ -49,17 +48,6 @@ export class NoctuaWidget extends HTMLElement { // Select and clone the appropriate Jinja2 HTML template this._render(meta, hasGet); // Bind click event listeners to execute actions (handles both GET and action buttons) this._setupEventListeners(); // Trigger an initial GET action automatically to load the state on boot if (hasGet) { const refreshBtn = this.querySelector('.btn-refresh'); if (refreshBtn) { this._executeAction(refreshBtn); } } } catch (error) { console.error("NoctuaWidget: Initialization failed", error); this.innerHTML = `<div class="alert alert-danger p-2 small">Error loading widget: ${error.message}</div>`; Loading @@ -82,16 +70,12 @@ export class NoctuaWidget extends HTMLElement { } /** * Standardize route paths by removing leading/trailing slashes and stripping * the optional 'api/' prefix for bulletproof matching. * Standardize route paths by removing leading/trailing slashes. */ _normalizePath(p) { // Remove leading and trailing slashes and convert to lowercase let path = p.replace(/^\/|\/$/g, '').toLowerCase(); // Strip the leading 'api/' segment if it exists in the endpoint string if (path.startsWith('api/')) { path = path.slice(4); // Remove 'api/' (4 characters) path = path.slice(4); } return path; } Loading Loading @@ -141,14 +125,14 @@ export class NoctuaWidget extends HTMLElement { // Clone the template content const clone = template.content.cloneNode(true); // Normalize route for data-* attribute assignments (e.g., "telescope-coordinates-movement-altaz") // Normalize route name (e.g., "telescope-coordinates-movement-altaz") const normalizedName = this.route.replace(/^\/|\/$/g, '').replace(/\//g, '-'); // Populate semantic elements inside the cloned markup // Populate and bind semantic elements this._populateLabelsAndUnits(clone, expects, hasGet); this._bindSemanticDataAttributes(clone, normalizedName); this._bindSemanticDataAttributes(clone, normalizedName, expects); // Clear loading indicators and append to Light DOM for easy Bootstrap styling // Clear loading indicators and append to Light DOM for easy Bootstrap and global script access this.innerHTML = ''; this.appendChild(clone); } Loading @@ -173,7 +157,6 @@ export class NoctuaWidget extends HTMLElement { badge.textContent = 'Write-Only'; badge.title = 'No telemetry available for this action'; } else { // Show manual refresh button if GET is supported if (refreshBtn) { refreshBtn.style.display = 'inline-block'; } Loading Loading @@ -213,182 +196,50 @@ export class NoctuaWidget extends HTMLElement { } /** * Map data-status, data-control, and data-parameter attributes. * Map data-status, data-control, data-parameter and standard id attributes for actions.js compatibility. */ _bindSemanticDataAttributes(clone, normalizedName) { _bindSemanticDataAttributes(clone, normalizedName, expects) { // Bind dynamic DOM IDs to inputs so actions.js can resolve them const input1 = clone.querySelector('.widget-input-1'); const input2 = clone.querySelector('.widget-input-2'); const id1 = `val1-${normalizedName}`; const id2 = `val2-${normalizedName}`; if (input1) { input1.id = id1; input1.setAttribute('data-parameter', normalizedName); } if (input2) { input2.id = id2; input2.setAttribute('data-parameter', normalizedName); } // Set websocket status listener const statusBadge = clone.querySelector('.widget-status-badge'); if (statusBadge) { statusBadge.id = `badge-${normalizedName}`; statusBadge.setAttribute('data-status', normalizedName); } // Bind control targets // Bind control targets compatibile with actions.js event delegation clone.querySelectorAll('.btn-control').forEach(btn => { btn.setAttribute('data-control', normalizedName); btn.classList.add('btn-universal'); // Add btn-universal class so actions.js intercepts clicks btn.setAttribute('data-safe-id', normalizedName); btn.setAttribute('data-url', `/${this.route.replace(/^\/|\/$/g, '')}`); // Set dynamic method ONLY if it is not pre-defined in the HTML template (like GET on the refresh button) const btnMethod = btn.getAttribute('data-method'); // If data-method is missing or empty string, fallback to default widget method if (!btnMethod || btnMethod.trim() === '') { btn.setAttribute('data-method', this.method); } }); // Bind parameter inputs clone.querySelectorAll('[class*="widget-input"]').forEach(input => { input.setAttribute('data-parameter', normalizedName); }); } /** * Bind click events on buttons to parse input parameters and trigger commands. */ _setupEventListeners() { // Handle trigger buttons (now includes both action buttons and the GET refresh button) this.querySelectorAll('.btn-control').forEach(btn => { btn.addEventListener('click', async (e) => { e.preventDefault(); await this._executeAction(btn); }); }); } /** * Extract parameters and perform the HTTP request. */ async _executeAction(btn) { const method = btn.getAttribute('data-method').toUpperCase(); const url = `/api/${this.route.replace(/^\/|\/$/g, '')}`; let payload = null; // Visual feedback during command transmission this._setLoadingState(true, method === 'GET'); if (method !== 'GET') { // 1. Resolve payload value for write operations const explicitValue = btn.getAttribute('data-value'); if (explicitValue !== null) { // Case A: Element has data-value (Preset button or toggle) try { payload = JSON.parse(explicitValue); } catch { payload = explicitValue; } } else { // Case B: Gather parameters from inputs const inputs = this.querySelectorAll(`[data-parameter]`); if (inputs.length === 1) { // Single input parameter const val = parseFloat(inputs[0].value); payload = isNaN(val) ? inputs[0].value : val; } else if (inputs.length > 1) { // Multi-parameter array payload = Array.from(inputs).map(inp => { const val = parseFloat(inp.value); return isNaN(val) ? inp.value : val; }); } } } // 2. Perform HTTP request to the Quart API try { const options = { method: method }; if (method !== 'GET' && payload !== null) { options.headers = { 'Content-Type': 'application/json' }; options.body = JSON.stringify(payload); } const response = await fetch(url, options); const result = await response.json(); if (response.ok && !result.error) { if (method === 'GET') { // Update telemetry readout with the actual GET response this._updateStatusBadge(result.response); } else { // Command succeeded: briefly flash the status badge green this._flashStatusBadge('bg-success', result.response); } } else { // Command failed: briefly flash the status badge red const errMsg = result.error ? result.error.join(', ') : 'Server Error'; this._flashStatusBadge('bg-danger', 'ERR'); console.error(`NoctuaWidget: Action failed: ${errMsg}`); } } catch (error) { this._flashStatusBadge('bg-danger', 'OFF'); console.error("NoctuaWidget: Network request failed", error); } finally { // FIX: Pass method context to correctly release GET buttons animation state this._setLoadingState(false, method === 'GET'); } } /** * Directly update the text of the local telemetry badge. */ _updateStatusBadge(value) { const badge = this.querySelector('.widget-status-badge'); if (badge) { // Check if badge is in disabled mode (do not overwrite 'Write-Only' cue) if (!badge.className.includes('text-muted')) { badge.textContent = value !== null ? value : 'N/A'; } } } /** * Flash status color as visual feedback. */ _flashStatusBadge(className, tempText) { const badge = this.querySelector('.widget-status-badge'); if (!badge) return; // Skip flashing if the badge is in disabled mode (no GET support) if (badge.className.includes('text-muted')) return; const originalClass = badge.className; // Briefly show success/error feedback color badge.className = `badge ${className} font-monospace`; badge.textContent = tempText; setTimeout(() => { // Restore only the neutral styling class, preserving the updated text value badge.className = originalClass; // Trigger a quick initial state update to fetch latest actual values const refreshBtn = this.querySelector('.btn-refresh'); if (refreshBtn) { this._executeAction(refreshBtn); } }, 1200); } /** * Disable inputs/buttons during network transit. */ _setLoadingState(isLoading, isGetOnly = false) { this.querySelectorAll('input, button').forEach(el => { // If it is a GET, only animate/disable the refresh button if (isGetOnly) { if (el.classList.contains('btn-refresh')) { if (isLoading) { el.classList.add('disabled'); el.style.transform = 'rotate(180deg)'; el.style.transition = 'transform 0.4s ease'; } else { setTimeout(() => { el.style.transform = 'none'; el.style.transition = 'none'; el.classList.remove('disabled'); }, 400); } } } else { // For write actions, disable inputs and non-refresh buttons if (!el.classList.contains('btn-refresh')) { el.disabled = isLoading; // Assign standard inputs parameter list to button dataset if (this.method !== 'GET') { if (expects.type === 'single') { btn.setAttribute('data-inputs', id1); } else if (expects.type === 'array' && expects.count === 2) { btn.setAttribute('data-inputs', `${id1}, ${id2}`); } } }); Loading
pyproject.toml +1 −1 Original line number Diff line number Diff line Loading @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "noctua" version = "0.4.1" version = "0.4.2" authors = [ { name="Davide Ricci", email="davide.ricci@inaf.it" }, ] Loading