Commit af432e3f authored by vertighel's avatar vertighel
Browse files

More non automatic widgets

parent 01a3a55f
Loading
Loading
Loading
Loading
Loading
+4 −2
Original line number Diff line number Diff line
@@ -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 %}
+0 −2
Original line number Diff line number Diff line
@@ -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 %}
+19 −3
Original line number Diff line number Diff line
@@ -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
+36 −185
Original line number Diff line number Diff line
@@ -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 = '';
    }
@@ -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>`;
@@ -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;
    }
@@ -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);
    }
@@ -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';
                }
@@ -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}`);
                }
            }
        });
+1 −1
Original line number Diff line number Diff line
@@ -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" },
]