Commit c805b61f authored by vertighel's avatar vertighel
Browse files

Synoptic with data-status

parent 6d6afec6
Loading
Loading
Loading
Loading
Loading
+13 −14
Original line number Diff line number Diff line
@@ -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>

@@ -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 }}">
@@ -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>
@@ -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 }}">
@@ -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>
@@ -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">
@@ -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 }}">
@@ -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>
@@ -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>
+8 −8
Original line number Diff line number Diff line
@@ -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>

@@ -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 %}
+936 −699

File changed.

Preview size limit exceeded, changes collapsed.

+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);
        }
    }
    });
});
+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