Commit 4c4ce118 authored by vertighel's avatar vertighel
Browse files

New js validated!

parent 3f1d5a2d
Loading
Loading
Loading
Loading
Loading
+9 −2
Original line number Diff line number Diff line
@@ -21,7 +21,7 @@ document.addEventListener('DOMContentLoaded', () => {
        try {
            let payload = null;

            // 1. Resolve payload value (Inputs -> data-value)
            // 1. Resolve payload value (Inputs -> Payload -> data-value)
            if (button.dataset.inputs) {
                // Dynamically collect input values if specified via comma-separated IDs
                const inputIds = button.dataset.inputs.split(',');
@@ -33,6 +33,13 @@ document.addEventListener('DOMContentLoaded', () => {
                    return isNaN(parsedNum) ? input.value : parsedNum;
                });
                payload = values.length === 1 ? values[0] : values;
            } else if (button.dataset.payload !== undefined) {
                // RISOLUZIONE BUG: Supporto per i payload complessi (usato da Sequencer e Expose)
                try {
                    payload = JSON.parse(button.dataset.payload);
                } catch {
                    payload = button.dataset.payload;
                }
            } else if (button.dataset.value !== undefined) {
                // Read from standard data-value attribute (JSON parsed)
                try {
@@ -61,7 +68,7 @@ document.addEventListener('DOMContentLoaded', () => {

            if (!response.ok) {
                const errorData = await response.json().catch(() => ({}));
                throw new Error(errorData.error ? errorData.error.join(', ') : 'Server Error');
                throw new Error(errorData.error ? (Array.isArray(errorData.error) ? errorData.error.join(', ') : errorData.error) : 'Server Error');
            }

            const result = await response.json();
+0 −260
Original line number Diff line number Diff line
// noctua/web/static/js/components/NoctuaWidget.js

// Global cache to prevent redundant API schema fetches
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();

        if (!this.route) {
            console.warn("NoctuaWidget: Missing 'route' attribute.");
            return;
        }

        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 {
                    meta = { method: this.method, expects: JSON.parse(overrideAttr) };
                } catch (e) {
                    console.error("NoctuaWidget: Failed to parse expects-override JSON", e);
                }
            }

            if (!meta && !overrideAttr) {
                this.innerHTML = `<div class="alert alert-danger p-2 small">Route ${this.route} [${this.method}] not found in API.</div>`;
                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 }));
                }
            }

        } catch (error) {
            console.error("NoctuaWidget: Initialization failed", error);
            this.innerHTML = `<div class="alert alert-danger p-2 small">Error loading widget: ${error.message}</div>`;
        }
    }

    /**
     * Fetch the centralized API schema from the backend.
     */
    async _getApiSchema() {
        if (apiSchemaCache) {
            return apiSchemaCache;
        }
        const response = await fetch('/api/');
        if (!response.ok) {
            throw new Error(`HTTP error! status: ${response.status}`);
        }
        apiSchemaCache = await response.json();
        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);
    }

    /**
     * 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');
    }

    /**
     * Choose, clone, and populate the appropriate HTML5 template.
     */
    _render(meta, hasGet) {
        const expects = meta.expects || {};
        let templateId = 'tpl-widget-readout'; // Default fallback

        // 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) {
            templateId = 'tpl-widget-action-only';
        } else if (expects.type === 'boolean') {
            templateId = 'tpl-widget-toggle';
        } else if (expects.type === 'array' && expects.count === 2) {
            templateId = 'tpl-widget-dual-input';
        } else if (expects.type === 'single' || expects.type === 'number' || expects.type === 'string') {
            templateId = 'tpl-widget-single-input';
        }

        const template = document.getElementById(templateId);
        if (!template) {
            console.error(`NoctuaWidget: Template with ID '${templateId}' not found.`);
            return;
        }

        // Clone the template content
        const clone = template.content.cloneNode(true);

        // 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');

        if (badge) {
            if (!hasGet) {
                badge.className = 'badge bg-black text-muted border border-secondary font-monospace';
                badge.textContent = 'Write-Only';
                badge.title = 'No telemetry available for this action';
            } else {
                if (refreshBtn) {
                    refreshBtn.style.display = 'inline-block';
                }
            }
        }

        // 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');
            const label1 = clone.querySelector('.widget-label-1');
            const label2 = clone.querySelector('.widget-label-2');

            if (input1) input1.placeholder = expects.placeholder ? expects.placeholder[0] || 'Val 1' : 'Val 1';
            if (input2) input2.placeholder = expects.placeholder ? expects.placeholder[1] || 'Val 2' : 'Val 2';
            if (label1) label1.textContent = 'Val 1';
            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');
        
        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 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.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);
                } else if (expects.type === 'array' && expects.count === 2) {
                    btn.setAttribute('data-inputs', `${id1}, ${id2}`);
                }
            }
        });
    }
}

// Register the custom HTML element
customElements.define('noctua-widget', NoctuaWidget);
+0 −112
Original line number Diff line number Diff line
/**
 * control.js
 * Logic for the Control page: Stage movement and Instrument Mode switching.
 */

import { FitsViewer } from './viewer/fits-viewer.js';

document.addEventListener('DOMContentLoaded', () => {
    
    // We fetch configured cameras from the data attributes of the container if possible,
    // or we assume standard scientific/technical cameras.
    const viewerContainer = document.getElementById('fits-viewer-container');
    const viewerTemplate = document.getElementById('tpl-fits-viewer');
    const stationLabel = document.getElementById('active-station-id');
    const modeLabel = document.getElementById('current-mode-label');

    let activeViewer = null;

    /**
     * Rebuild the FITS viewer DOM from blueprint and initialize logic.
     * 
     * @param {string} station - The observatory station ID (station1, station2, etc.)
     */
    function switchViewer(station) {
        if (!viewerTemplate || !viewerContainer) return;

        // Clear current monitor and clone a fresh UI structure from blueprint
        viewerContainer.innerHTML = '';
        const clone = viewerTemplate.content.cloneNode(true);
        viewerContainer.appendChild(clone);

        // Map station ID to human-readable mode labels
        const modeMap = { 'station1': 'Imaging', 'station2': 'Spectro', 'station3': 'Échelle' };
        if (stationLabel) stationLabel.textContent = station.toUpperCase();
        if (modeLabel) modeLabel.textContent = modeMap[station] || 'Unknown';

        // Station1 usually has sci+tec, others might vary. 
        // For Control page, we focus on the primary scientific camera of the station.
        const cameras = ['scicam', 'teccam']; 

        // Initialize the viewer engine
        // IMPORTANT: we pass 'cameras' to ensure all sub-elements find their hooks
        activeViewer = new FitsViewer(station, viewerContainer, '/api', cameras);
        
        activeViewer.refresh();
    }


    // 1. Mode Selector Logic
    document.querySelectorAll('input[name="obs-mode"]').forEach(radio => {
        radio.addEventListener('change', (e) => {
            const station = e.target.value;
            switchViewer(station);
            
            // Set active station metadata for the Expose bridge
            const btnExpose = document.getElementById('btn-camera-expose');
            if (btnExpose) btnExpose.dataset.activeStation = station;
        });
    });


    // 2. Stage Relative Movement Logic
    const inputRel = document.getElementById('stage-rel-val');
    
    const moveStageRelative = async (direction) => {
        const step = parseFloat(inputRel.value) || 0;
        const delta = direction * step;
        
        try {
            const res = await fetch('/api/stage/position');
            const data = await res.json();
            const currentPos = data.response || 0;
            
            await fetch('/api/stage/position', {
                method: 'PUT',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify(currentPos + delta)
            });
        } catch (err) {
            console.error("Stage relative move failed:", err);
        }
    };

    document.getElementById('btn-stage-rel-plus')?.addEventListener('click', () => moveStageRelative(1));
    document.getElementById('btn-stage-rel-minus')?.addEventListener('click', () => moveStageRelative(-1));


    // 3. Camera "Expose" Bridge
    const btnExpose = document.getElementById('btn-camera-expose');
    if (btnExpose) {
        btnExpose.addEventListener('mousedown', () => {
            const params = {};
            document.querySelectorAll('[data-parameter^="observation-"]').forEach(input => {
                const key = input.dataset.parameter.replace('observation-', '');
                if (input.type === 'checkbox') params[key] = input.checked;
                else params[key] = isNaN(input.value) || input.value === '' ? input.value : parseFloat(input.value);
            });

            // The sequencer will handle instrument logic based on the active station
            const station = btnExpose.dataset.activeStation || 'station1';
            
            btnExpose.dataset.payload = JSON.stringify({
                "template": "observation",
                "params": params
            });
        });
    }


    // Default initialization to Station 1 (Imaging)
    switchViewer('station1');
});
+0 −341

File deleted.

Preview size limit exceeded, changes collapsed.

+0 −133
Original line number Diff line number Diff line
// status-stream.js
// Standard telemetry listener updating control panels with data-status attributes.

document.addEventListener('DOMContentLoaded', () => {
    const previousState = {};

    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;

        if (!previousState[subsystem]) previousState[subsystem] = {};

        const elements = document.querySelectorAll(`[data-status^="${subsystem}-"]`);

        elements.forEach(el => {
            const statusKey = el.dataset.status;

            // --- ALGORITMO DI PARSING A RITROSO (UNIFICATO) ---
            const keyWithoutSubsystem = statusKey.replace(`${subsystem}-`, '');
            
            let arrayIndex = null;
            let remainingKey = keyWithoutSubsystem;

            if (remainingKey.endsWith('-0') || remainingKey.endsWith('-1')) {
                arrayIndex = parseInt(remainingKey.slice(-1), 10);
                remainingKey = remainingKey.slice(0, -2);
            }

            let endpoint = '';
            let subProperty = '';

            if (data[remainingKey] !== undefined) {
                endpoint = remainingKey;
            } else {
                const lastHyphenIdx = remainingKey.lastIndexOf('-');
                if (lastHyphenIdx !== -1) {
                    endpoint = remainingKey.substring(0, lastHyphenIdx);
                    subProperty = remainingKey.substring(lastHyphenIdx + 1);
                } else {
                    endpoint = remainingKey;
                }
            }

            const endpointData = data[endpoint];
            if (endpointData === undefined) return;

            // Gestione dei badge di errore (confronto atomico dello stato di errore)
            if (statusKey.endsWith('-error')) {
                const errors = endpointData.error;
                const hasErrors = errors && errors.length > 0;
                const displayValue = hasErrors ? 'ERR' : 'OK';

                const prevErrorState = previousState[subsystem][statusKey];
                const hasChanged = prevErrorState !== undefined && prevErrorState !== displayValue;
                previousState[subsystem][statusKey] = displayValue;

                if (hasChanged) {
                    el.classList.add('pulse-update');
                    setTimeout(() => el.classList.remove('pulse-update'), 50);
                }

                el.textContent = displayValue;
                el.className = `badge ${hasErrors ? 'bg-danger' : 'bg-success'}`;
                if (hasErrors) el.setAttribute('title', errors.join(', '));
                else el.removeAttribute('title');
                return;
            }

            let core = (endpointData && typeof endpointData === 'object' && endpointData.response !== undefined) 
                       ? endpointData.response 
                       : endpointData;

            let finalValue = core;

            if (subProperty && core && typeof core === 'object') {
                finalValue = core[subProperty];
            }

            if (arrayIndex !== null && Array.isArray(finalValue)) {
                finalValue = finalValue[arrayIndex];
            }

            const transformName = el.dataset.transform;
            if (transformName && window.noctuaTransforms && typeof window.noctuaTransforms[transformName] === 'function') {
                try {
                    if (finalValue !== null && finalValue !== undefined) {
                        finalValue = window.noctuaTransforms[transformName](finalValue);
                    }
                } catch (e) {
                    console.warn(`status-stream: Transform '${transformName}' failed on value ${finalValue}`, e);
                }
            }

            let displayValue = (finalValue === null || finalValue === undefined) ? 'N/A' : finalValue;
            
            if (typeof displayValue === 'boolean') {
                displayValue = displayValue ? 'Yes' : 'No';
            }

            const valStr = displayValue.toString();

            // 1. Aggiornamento del Contenuto Testuale
            if (el.tagName.toLowerCase() === 'input') {
                if (el.type === 'checkbox') el.checked = (valStr === 'Yes');
                else el.value = valStr;
            } else {
                el.textContent = valStr;
            }

            // 2. Applicazione dello stile dinamico
            if (el.tagName.toLowerCase() === 'var' || el.classList.contains('badge')) {
                if (typeof applyVarStatusStyles === 'function') {
                    applyVarStatusStyles(el, finalValue);
                }
            }

            // 3. Rilevamento dei Cambiamenti Atomici (FOGLIA) e Animazione Pulse
            const hasChanged = previousState[subsystem][statusKey] !== undefined && 
                               previousState[subsystem][statusKey] !== finalValue;
            
            previousState[subsystem][statusKey] = finalValue;

            if (hasChanged) {
                el.classList.add('pulse-update');
                setTimeout(() => el.classList.remove('pulse-update'), 50);
            }
        });
    });
});
Loading