Loading noctua/web/pages/base.html +12 −12 Original line number Diff line number Diff line Loading @@ -157,21 +157,21 @@ </div> </footer> <!-- ── Vendor scripts ─────────────────────────────────────────────────── --> <script type="module" src="{{ url_for('web.static', filename='js/ui-utils.js') }}"></script> <!-- Prima i fogli di utilità principali caricati come moduli --> <script type="module" src="{{ url_for('web.static', filename='js/ui-core.js') }}"></script> <script type="module" src="{{ url_for('web.static', filename='js/ui.js') }}"></script> <!-- Vendor esterni tradizionali non-modulari --> <script src="{{ url_for('web.static', filename='js/vendor/bootstrap.bundle.min.js') }}"></script> <script src="{{ url_for('web.static', filename='js/vendor/ansi_up.js') }}"></script> <!-- ── UI primitives (must come before actions / status scripts) ──────── --> <script src="{{ url_for('web.static', filename='js/ui.js') }}"></script> <!-- ── Noctua Web Components ──────────────────────────────── --> <!-- I moduli reattivi e di comunicazione --> <script type="module" src="{{ url_for('web.static', filename='js/status-stream.js') }}"></script> <script type="module" src="{{ url_for('web.static', filename='js/status-view.js') }}"></script> <script type="module" src="{{ url_for('web.static', filename='js/ws-client.js') }}"></script> <!-- ── Unified WebSocket client ───────────────────────────────────────── --> <script src="{{ url_for('web.static', filename='js/ui-styles.js') }}"></script> <script src="{{ url_for('web.static', filename='js/status-stream.js') }}"></script> <!-- Gli altri script di pagina tradizionali e web components --> <script src="{{ url_for('web.static', filename='js/actions.js') }}"></script> <script src="{{ url_for('web.static', filename='js/ws-client.js') }}"></script> <script type="module" src="{{ url_for('web.static', filename='js/components/NoctuaWidget.js') }}"></script> {% block scripts %}{% endblock %} Loading noctua/web/pages/sequencer.html +1 −1 Original line number Diff line number Diff line Loading @@ -57,6 +57,6 @@ {% endblock %} {% block scripts %} <script src="{{ url_for('web.static', filename='js/sequencer.js') }}"></script> <script type="module" src="{{ url_for('web.static', filename='js/sequencer.js') }}"></script> {% endblock %} noctua/web/pages/status.html +1 −1 Original line number Diff line number Diff line Loading @@ -39,5 +39,5 @@ {% endblock %} {% block scripts %} <script src="{{ url_for('web.static', filename='js/status-view.js') }}"></script> <script type="module" src="{{ url_for('web.static', filename='js/status-view.js') }}"></script> {% endblock %} noctua/web/pages/synoptic.html +1 −1 Original line number Diff line number Diff line Loading @@ -23,5 +23,5 @@ window.SYNOPTIC_SVG_URL = "{{ url_for('web.static', filename='img/synoptic.svg') }}"; </script> <!-- ── Synoptic animation ───────────────────────────────────────── --> <script src="{{ url_for('web.static', filename='js/synoptic.js') }}"></script> <script type="module" src="{{ url_for('web.static', filename='js/synoptic.js') }}"></script> {% endblock %} noctua/web/static/js/components/NoctuaWidget.js +11 −66 Original line number Diff line number Diff line // noctua/web/static/js/components/NoctuaWidget.js // NoctuaWidget.js // Dynamic Web Component for automatic UI Widget generation. import { normalizePath } from '../ui-core.js'; // Global cache to prevent redundant API schema fetches let apiSchemaCache = null; Loading @@ -6,13 +9,11 @@ let apiSchemaCache = null; export class NoctuaWidget extends HTMLElement { constructor() { super(); // Do not read DOM attributes here, wait for connectedCallback this.route = ''; this.method = ''; } async connectedCallback() { // Read HTML attributes now that the element is attached to the DOM this.route = this.getAttribute('route') || ''; this.method = (this.getAttribute('method') || 'get').toUpperCase(); Loading @@ -22,13 +23,9 @@ export class NoctuaWidget extends HTMLElement { } try { // Load the API schema if not already cached const schema = await this._getApiSchema(); // Find the endpoint metadata let meta = this._findEndpointMetadata(schema); // Handle manual override for previews/mock devices const overrideAttr = this.getAttribute('expects-override'); if (overrideAttr) { try { Loading @@ -43,17 +40,12 @@ export class NoctuaWidget extends HTMLElement { return; } // Determine if the resource has GET capability on the exact same endpoint const hasGet = this._checkGetCapability(schema); // Select and clone the appropriate Jinja2 HTML template this._render(meta, hasGet); // Trigger an initial GET action automatically to load the state on boot (using actions.js trigger) if (hasGet) { const refreshBtn = this.querySelector('.btn-refresh'); if (refreshBtn) { // Programmatically trigger a click event to let actions.js fetch the initial state (English comment) refreshBtn.dispatchEvent(new Event('click', { bubbles: true })); } } Loading @@ -64,9 +56,6 @@ export class NoctuaWidget extends HTMLElement { } } /** * Fetch the centralized API schema from the backend. */ async _getApiSchema() { if (apiSchemaCache) { return apiSchemaCache; Loading @@ -79,41 +68,20 @@ export class NoctuaWidget extends HTMLElement { return apiSchemaCache; } /** * Standardize route paths by removing leading/trailing slashes. */ _normalizePath(p) { let path = p.replace(/^\/|\/$/g, '').toLowerCase(); if (path.startsWith('api/')) { path = path.slice(4); } return path; } /** * Find the matching route and method configuration in the schema catalog. */ _findEndpointMetadata(schema) { const target = this._normalizePath(this.route); return schema.find(item => this._normalizePath(item.endpoint) === target && item.method === this.method); const target = normalizePath(this.route); return schema.find(item => normalizePath(item.endpoint) === target && item.method === this.method); } /** * Verify if the endpoint supports the GET method in the registry. */ _checkGetCapability(schema) { const target = this._normalizePath(this.route); return schema.some(item => this._normalizePath(item.endpoint) === target && item.method === 'GET'); const target = normalizePath(this.route); return schema.some(item => normalizePath(item.endpoint) === target && item.method === 'GET'); } /** * Choose, clone, and populate the appropriate HTML5 template. */ _render(meta, hasGet) { const expects = meta.expects || {}; let templateId = 'tpl-widget-readout'; // Default fallback let templateId = 'tpl-widget-readout'; // Explicitly check for GET first to avoid empty button fallbacks if (this.method === 'GET') { templateId = 'tpl-widget-readout'; } else if (this.method === 'DELETE' || !meta.expects) { Loading @@ -132,32 +100,22 @@ export class NoctuaWidget extends HTMLElement { return; } // Clone the template content const clone = template.content.cloneNode(true); const normalizedName = normalizePath(this.route); // Normalize route name (e.g., "telescope-coordinates-movement-altaz") const normalizedName = this.route.replace(/^\/|\/$/g, '').replace(/\//g, '-'); // Populate and bind semantic elements this._populateLabelsAndUnits(clone, expects, hasGet); this._bindSemanticDataAttributes(clone, normalizedName, expects); // Clear loading indicators and append to Light DOM for easy Bootstrap and global script access this.innerHTML = ''; this.appendChild(clone); } /** * Inject custom labels, units, and placeholders from metadata. */ _populateLabelsAndUnits(clone, expects, hasGet) { // Set main title from route const titleEl = clone.querySelector('.widget-title'); if (titleEl) { titleEl.textContent = this.route.split('/').pop().replace(/_/g, ' '); } // Handle disabled visual cue for write-only endpoints const badge = clone.querySelector('.widget-status-badge'); const refreshBtn = clone.querySelector('.btn-refresh'); Loading @@ -173,19 +131,16 @@ export class NoctuaWidget extends HTMLElement { } } // Set unit badge const unitEl = clone.querySelector('.widget-unit'); if (unitEl && expects.unit) { unitEl.textContent = expects.unit; } // Handle specific configurations for single input (supports number and string types) const singleInput = clone.querySelector('.widget-input-1'); if (singleInput && (expects.type === 'single' || expects.type === 'number' || expects.type === 'string')) { if (expects.placeholder) singleInput.placeholder = expects.placeholder; } // Handle specific configurations for dual inputs if (expects.type === 'array' && expects.count === 2) { const input1 = clone.querySelector('.widget-input-1'); const input2 = clone.querySelector('.widget-input-2'); Loading @@ -198,18 +153,13 @@ export class NoctuaWidget extends HTMLElement { if (label2) label2.textContent = 'Val 2'; } // Handle text label inside impulse buttons (DELETE or parameterless actions) const actionBtn = clone.querySelector('.btn-control:not(.btn-refresh)'); if (actionBtn && (this.method === 'DELETE' || !expects.type)) { actionBtn.textContent = this.method === 'DELETE' ? 'ABORT' : 'EXECUTE'; } } /** * Map data-status, data-control, data-parameter and standard id attributes for actions.js compatibility. */ _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'); Loading @@ -225,26 +175,22 @@ export class NoctuaWidget extends HTMLElement { 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 compatibile with actions.js event delegation clone.querySelectorAll('.btn-control').forEach(btn => { btn.classList.add('btn-universal'); // Add btn-universal class so actions.js intercepts clicks btn.classList.add('btn-universal'); 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 (!btnMethod || btnMethod.trim() === '') { btn.setAttribute('data-method', this.method); } // Assign standard inputs parameter list to button dataset if (this.method !== 'GET') { if (expects.type === 'single' || expects.type === 'number' || expects.type === 'string') { btn.setAttribute('data-inputs', id1); Loading @@ -256,5 +202,4 @@ export class NoctuaWidget extends HTMLElement { } } // Register the custom HTML element customElements.define('noctua-widget', NoctuaWidget); Loading
noctua/web/pages/base.html +12 −12 Original line number Diff line number Diff line Loading @@ -157,21 +157,21 @@ </div> </footer> <!-- ── Vendor scripts ─────────────────────────────────────────────────── --> <script type="module" src="{{ url_for('web.static', filename='js/ui-utils.js') }}"></script> <!-- Prima i fogli di utilità principali caricati come moduli --> <script type="module" src="{{ url_for('web.static', filename='js/ui-core.js') }}"></script> <script type="module" src="{{ url_for('web.static', filename='js/ui.js') }}"></script> <!-- Vendor esterni tradizionali non-modulari --> <script src="{{ url_for('web.static', filename='js/vendor/bootstrap.bundle.min.js') }}"></script> <script src="{{ url_for('web.static', filename='js/vendor/ansi_up.js') }}"></script> <!-- ── UI primitives (must come before actions / status scripts) ──────── --> <script src="{{ url_for('web.static', filename='js/ui.js') }}"></script> <!-- ── Noctua Web Components ──────────────────────────────── --> <!-- I moduli reattivi e di comunicazione --> <script type="module" src="{{ url_for('web.static', filename='js/status-stream.js') }}"></script> <script type="module" src="{{ url_for('web.static', filename='js/status-view.js') }}"></script> <script type="module" src="{{ url_for('web.static', filename='js/ws-client.js') }}"></script> <!-- ── Unified WebSocket client ───────────────────────────────────────── --> <script src="{{ url_for('web.static', filename='js/ui-styles.js') }}"></script> <script src="{{ url_for('web.static', filename='js/status-stream.js') }}"></script> <!-- Gli altri script di pagina tradizionali e web components --> <script src="{{ url_for('web.static', filename='js/actions.js') }}"></script> <script src="{{ url_for('web.static', filename='js/ws-client.js') }}"></script> <script type="module" src="{{ url_for('web.static', filename='js/components/NoctuaWidget.js') }}"></script> {% block scripts %}{% endblock %} Loading
noctua/web/pages/sequencer.html +1 −1 Original line number Diff line number Diff line Loading @@ -57,6 +57,6 @@ {% endblock %} {% block scripts %} <script src="{{ url_for('web.static', filename='js/sequencer.js') }}"></script> <script type="module" src="{{ url_for('web.static', filename='js/sequencer.js') }}"></script> {% endblock %}
noctua/web/pages/status.html +1 −1 Original line number Diff line number Diff line Loading @@ -39,5 +39,5 @@ {% endblock %} {% block scripts %} <script src="{{ url_for('web.static', filename='js/status-view.js') }}"></script> <script type="module" src="{{ url_for('web.static', filename='js/status-view.js') }}"></script> {% endblock %}
noctua/web/pages/synoptic.html +1 −1 Original line number Diff line number Diff line Loading @@ -23,5 +23,5 @@ window.SYNOPTIC_SVG_URL = "{{ url_for('web.static', filename='img/synoptic.svg') }}"; </script> <!-- ── Synoptic animation ───────────────────────────────────────── --> <script src="{{ url_for('web.static', filename='js/synoptic.js') }}"></script> <script type="module" src="{{ url_for('web.static', filename='js/synoptic.js') }}"></script> {% endblock %}
noctua/web/static/js/components/NoctuaWidget.js +11 −66 Original line number Diff line number Diff line // noctua/web/static/js/components/NoctuaWidget.js // NoctuaWidget.js // Dynamic Web Component for automatic UI Widget generation. import { normalizePath } from '../ui-core.js'; // Global cache to prevent redundant API schema fetches let apiSchemaCache = null; Loading @@ -6,13 +9,11 @@ let apiSchemaCache = null; export class NoctuaWidget extends HTMLElement { constructor() { super(); // Do not read DOM attributes here, wait for connectedCallback this.route = ''; this.method = ''; } async connectedCallback() { // Read HTML attributes now that the element is attached to the DOM this.route = this.getAttribute('route') || ''; this.method = (this.getAttribute('method') || 'get').toUpperCase(); Loading @@ -22,13 +23,9 @@ export class NoctuaWidget extends HTMLElement { } try { // Load the API schema if not already cached const schema = await this._getApiSchema(); // Find the endpoint metadata let meta = this._findEndpointMetadata(schema); // Handle manual override for previews/mock devices const overrideAttr = this.getAttribute('expects-override'); if (overrideAttr) { try { Loading @@ -43,17 +40,12 @@ export class NoctuaWidget extends HTMLElement { return; } // Determine if the resource has GET capability on the exact same endpoint const hasGet = this._checkGetCapability(schema); // Select and clone the appropriate Jinja2 HTML template this._render(meta, hasGet); // Trigger an initial GET action automatically to load the state on boot (using actions.js trigger) if (hasGet) { const refreshBtn = this.querySelector('.btn-refresh'); if (refreshBtn) { // Programmatically trigger a click event to let actions.js fetch the initial state (English comment) refreshBtn.dispatchEvent(new Event('click', { bubbles: true })); } } Loading @@ -64,9 +56,6 @@ export class NoctuaWidget extends HTMLElement { } } /** * Fetch the centralized API schema from the backend. */ async _getApiSchema() { if (apiSchemaCache) { return apiSchemaCache; Loading @@ -79,41 +68,20 @@ export class NoctuaWidget extends HTMLElement { return apiSchemaCache; } /** * Standardize route paths by removing leading/trailing slashes. */ _normalizePath(p) { let path = p.replace(/^\/|\/$/g, '').toLowerCase(); if (path.startsWith('api/')) { path = path.slice(4); } return path; } /** * Find the matching route and method configuration in the schema catalog. */ _findEndpointMetadata(schema) { const target = this._normalizePath(this.route); return schema.find(item => this._normalizePath(item.endpoint) === target && item.method === this.method); const target = normalizePath(this.route); return schema.find(item => normalizePath(item.endpoint) === target && item.method === this.method); } /** * Verify if the endpoint supports the GET method in the registry. */ _checkGetCapability(schema) { const target = this._normalizePath(this.route); return schema.some(item => this._normalizePath(item.endpoint) === target && item.method === 'GET'); const target = normalizePath(this.route); return schema.some(item => normalizePath(item.endpoint) === target && item.method === 'GET'); } /** * Choose, clone, and populate the appropriate HTML5 template. */ _render(meta, hasGet) { const expects = meta.expects || {}; let templateId = 'tpl-widget-readout'; // Default fallback let templateId = 'tpl-widget-readout'; // Explicitly check for GET first to avoid empty button fallbacks if (this.method === 'GET') { templateId = 'tpl-widget-readout'; } else if (this.method === 'DELETE' || !meta.expects) { Loading @@ -132,32 +100,22 @@ export class NoctuaWidget extends HTMLElement { return; } // Clone the template content const clone = template.content.cloneNode(true); const normalizedName = normalizePath(this.route); // Normalize route name (e.g., "telescope-coordinates-movement-altaz") const normalizedName = this.route.replace(/^\/|\/$/g, '').replace(/\//g, '-'); // Populate and bind semantic elements this._populateLabelsAndUnits(clone, expects, hasGet); this._bindSemanticDataAttributes(clone, normalizedName, expects); // Clear loading indicators and append to Light DOM for easy Bootstrap and global script access this.innerHTML = ''; this.appendChild(clone); } /** * Inject custom labels, units, and placeholders from metadata. */ _populateLabelsAndUnits(clone, expects, hasGet) { // Set main title from route const titleEl = clone.querySelector('.widget-title'); if (titleEl) { titleEl.textContent = this.route.split('/').pop().replace(/_/g, ' '); } // Handle disabled visual cue for write-only endpoints const badge = clone.querySelector('.widget-status-badge'); const refreshBtn = clone.querySelector('.btn-refresh'); Loading @@ -173,19 +131,16 @@ export class NoctuaWidget extends HTMLElement { } } // Set unit badge const unitEl = clone.querySelector('.widget-unit'); if (unitEl && expects.unit) { unitEl.textContent = expects.unit; } // Handle specific configurations for single input (supports number and string types) const singleInput = clone.querySelector('.widget-input-1'); if (singleInput && (expects.type === 'single' || expects.type === 'number' || expects.type === 'string')) { if (expects.placeholder) singleInput.placeholder = expects.placeholder; } // Handle specific configurations for dual inputs if (expects.type === 'array' && expects.count === 2) { const input1 = clone.querySelector('.widget-input-1'); const input2 = clone.querySelector('.widget-input-2'); Loading @@ -198,18 +153,13 @@ export class NoctuaWidget extends HTMLElement { if (label2) label2.textContent = 'Val 2'; } // Handle text label inside impulse buttons (DELETE or parameterless actions) const actionBtn = clone.querySelector('.btn-control:not(.btn-refresh)'); if (actionBtn && (this.method === 'DELETE' || !expects.type)) { actionBtn.textContent = this.method === 'DELETE' ? 'ABORT' : 'EXECUTE'; } } /** * Map data-status, data-control, data-parameter and standard id attributes for actions.js compatibility. */ _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'); Loading @@ -225,26 +175,22 @@ export class NoctuaWidget extends HTMLElement { 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 compatibile with actions.js event delegation clone.querySelectorAll('.btn-control').forEach(btn => { btn.classList.add('btn-universal'); // Add btn-universal class so actions.js intercepts clicks btn.classList.add('btn-universal'); 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 (!btnMethod || btnMethod.trim() === '') { btn.setAttribute('data-method', this.method); } // Assign standard inputs parameter list to button dataset if (this.method !== 'GET') { if (expects.type === 'single' || expects.type === 'number' || expects.type === 'string') { btn.setAttribute('data-inputs', id1); Loading @@ -256,5 +202,4 @@ export class NoctuaWidget extends HTMLElement { } } // Register the custom HTML element customElements.define('noctua-widget', NoctuaWidget);