(() => { const formatValue = (value) => { if (value === null || value === undefined || Number.isNaN(Number(value))) { return '—'; } const numeric = Number(value); const absValue = Math.abs(numeric); let maximumFractionDigits = 0; if (absValue !== Math.trunc(absValue)) { maximumFractionDigits = absValue < 1 ? 3 : absValue < 10 ? 2 : 1; } return numeric.toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits }); }; document.addEventListener('DOMContentLoaded', () => { const app = document.querySelector('[data-control-app]'); if (!app) { return; } const select = app.querySelector('[data-control-select]'); const typeFilter = app.querySelector('[data-control-type-filter]'); const valueDisplay = app.querySelector('[data-current-value]'); const timestampDisplay = app.querySelector('[data-current-timestamp]'); const valueLabel = app.querySelector('[data-value-label]'); const pendingIndicator = app.querySelector('[data-pending-indicator]'); const numericContainer = app.querySelector('[data-numeric-controls]'); const booleanContainer = app.querySelector('[data-boolean-controls]'); const stepInput = app.querySelector('[data-step-input]'); const setContainer = app.querySelector('[data-set-form]'); const setInput = app.querySelector('[data-set-input]'); const applyButton = app.querySelector('[data-action="apply"]'); const decreaseBtn = app.querySelector('[data-action="decrease"]'); const increaseBtn = app.querySelector('[data-action="increase"]'); const booleanOffBtn = app.querySelector('[data-boolean="off"]'); const booleanOnBtn = app.querySelector('[data-boolean="on"]'); const refreshBtn = app.querySelector('[data-action="refresh"]'); const status = app.querySelector('[data-status]'); const defaultNumericSettings = { stepValue: stepInput ? Number(stepInput.value) || 1 : 1, stepAttr: stepInput ? (stepInput.getAttribute('step') || '0.1') : '0.1', stepMin: stepInput ? (stepInput.getAttribute('min') || '') : '', setStepAttr: setInput ? (setInput.getAttribute('step') || '0.1') : '0.1', placeholder: setInput ? (setInput.getAttribute('placeholder') || '') : '' }; const getUrl = app.dataset.getUrl; const updateUrl = app.dataset.updateUrl; const csrfToken = app.dataset.csrf; if (!select || !getUrl || !updateUrl) { return; } const placeholderOption = select.options.length > 0 ? select.options[0] : null; const placeholderDefaultText = placeholderOption ? placeholderOption.textContent : ''; const state = { busy: false, selectedId: select ? (select.value || null) : null, statusTimeout: null, selectedMode: 'numeric', currentValue: null, stagedValue: null, numericConfig: null, datasetUnits: null, units: null, description: null }; const parseDatasetNumber = (value) => { if (value === undefined || value === null || value === '') { return null; } const numeric = Number(value); return Number.isFinite(numeric) ? numeric : null; }; const extractNumericConfig = (option) => { if (!option) { return null; } const step = parseDatasetNumber(option.dataset.stepSize); const min = parseDatasetNumber(option.dataset.minValue); const max = parseDatasetNumber(option.dataset.maxValue); const rawPrecision = option.dataset.precision; const precisionNumber = rawPrecision === undefined || rawPrecision === null || rawPrecision === '' ? null : Number(rawPrecision); const precision = Number.isFinite(precisionNumber) && precisionNumber >= 0 ? Math.round(precisionNumber) : null; const units = option.dataset.units && option.dataset.units !== '' ? option.dataset.units : null; const description = option.dataset.description && option.dataset.description !== '' ? option.dataset.description : null; return { step: typeof step === 'number' && step > 0 ? step : null, min: typeof min === 'number' ? min : null, max: typeof max === 'number' ? max : null, precision, units, description }; }; const delay = (ms) => new Promise((resolve) => { window.setTimeout(resolve, ms); }); const applyNumericConstraints = (value) => { if (typeof value !== 'number' || Number.isNaN(value)) { return value; } const config = state.numericConfig; if (!config) { return value; } let result = value; if (typeof config.min === 'number' && result < config.min) { result = config.min; } if (typeof config.max === 'number' && result > config.max) { result = config.max; } if (typeof config.precision === 'number' && config.precision >= 0) { result = Number(result.toFixed(config.precision)); } return result; }; const getUnitsLabel = () => state.units || state.datasetUnits || null; const applyNumericConfig = (option) => { const config = extractNumericConfig(option); state.numericConfig = config; state.datasetUnits = config ? config.units : null; state.description = config ? config.description : null; state.units = null; if (stepInput) { const stepValue = config && typeof config.step === 'number' ? config.step : defaultNumericSettings.stepValue; stepInput.value = stepValue; stepInput.step = config && typeof config.step === 'number' ? config.step : defaultNumericSettings.stepAttr; if (config && typeof config.min === 'number') { stepInput.min = config.min; } else if (defaultNumericSettings.stepMin !== '') { stepInput.min = defaultNumericSettings.stepMin; } else { stepInput.removeAttribute('min'); } } if (setInput) { if (config && typeof config.min === 'number') { setInput.min = config.min; } else { setInput.removeAttribute('min'); } if (config && typeof config.max === 'number') { setInput.max = config.max; } else { setInput.removeAttribute('max'); } if (config && typeof config.step === 'number') { setInput.step = config.step; } else { setInput.step = defaultNumericSettings.setStepAttr; } if (config && config.units) { setInput.placeholder = config.units; } else { setInput.placeholder = defaultNumericSettings.placeholder; } } }; const getActionableElements = () => [ stepInput, setInput, applyButton, decreaseBtn, increaseBtn, refreshBtn, booleanOffBtn, booleanOnBtn ].filter(Boolean); const resolveMode = (hint) => (hint === 'boolean' ? 'boolean' : 'numeric'); const isBooleanMode = () => state.selectedMode === 'boolean'; const valuesEqual = (left, right) => { if (left === null || left === undefined) { return right === null || right === undefined; } if (right === null || right === undefined) { return false; } const leftNumber = Number(left); const rightNumber = Number(right); if (Number.isNaN(leftNumber) || Number.isNaN(rightNumber)) { return left === right; } return Math.abs(leftNumber - rightNumber) < 1e-9; }; const parseNumericValue = (value) => { const numeric = Number(value); return Number.isFinite(numeric) ? numeric : null; }; const formatBooleanDisplay = (value) => { if (value === null || value === undefined) { return '—'; } const numeric = Number(value); if (!Number.isFinite(numeric)) { return String(value); } return numeric >= 0.5 ? 'ON' : 'OFF'; }; const updateBooleanButtons = (value) => { if (!booleanOnBtn || !booleanOffBtn) { return; } const numeric = Number(value); if (!Number.isFinite(numeric)) { booleanOnBtn.classList.remove('button--toggle-active'); booleanOffBtn.classList.remove('button--toggle-active'); booleanOnBtn.setAttribute('aria-pressed', 'false'); booleanOffBtn.setAttribute('aria-pressed', 'false'); return; } const isOn = numeric >= 0.5; booleanOnBtn.classList.toggle('button--toggle-active', isOn); booleanOffBtn.classList.toggle('button--toggle-active', !isOn); booleanOnBtn.setAttribute('aria-pressed', isOn ? 'true' : 'false'); booleanOffBtn.setAttribute('aria-pressed', !isOn ? 'true' : 'false'); }; const getVisualValue = () => { if (isBooleanMode()) { return state.currentValue; } if (Number.isFinite(state.stagedValue)) { return state.stagedValue; } return state.currentValue; }; const updatePendingIndicator = () => { if (!pendingIndicator) { return; } const pending = Boolean( state.selectedId && !isBooleanMode() && Number.isFinite(state.stagedValue) && !valuesEqual(state.stagedValue, state.currentValue) ); pendingIndicator.hidden = !pending; }; const refreshValueDisplay = () => { if (valueDisplay) { const visualValue = getVisualValue(); valueDisplay.textContent = isBooleanMode() ? formatBooleanDisplay(visualValue) : formatValue(visualValue); } updatePendingIndicator(); }; const isApplyReady = () => Boolean( state.selectedId && !isBooleanMode() && Number.isFinite(state.stagedValue) && !valuesEqual(state.stagedValue, state.currentValue) ); const updateApplyAvailability = () => { if (!applyButton) { return; } applyButton.disabled = state.busy || !isApplyReady(); }; const setMode = (modeHint) => { const mode = resolveMode(modeHint); state.selectedMode = mode; if (numericContainer) { numericContainer.hidden = mode !== 'numeric'; } if (booleanContainer) { booleanContainer.hidden = mode !== 'boolean'; } if (valueLabel) { const unitsLabel = getUnitsLabel(); valueLabel.textContent = mode === 'boolean' ? 'Current state' : `Current value${unitsLabel ? ` (${unitsLabel})` : ''}`; } if (setInput) { if (mode === 'numeric') { const unitsLabel = getUnitsLabel(); setInput.placeholder = unitsLabel || defaultNumericSettings.placeholder; } else { setInput.placeholder = ''; } } if (!isBooleanMode() && setInput && !Number.isFinite(state.stagedValue) && Number.isFinite(state.currentValue)) { const constrainedValue = applyNumericConstraints(state.currentValue); setInput.value = Number.isFinite(constrainedValue) ? constrainedValue : state.currentValue; } if (isBooleanMode()) { updateBooleanButtons(state.currentValue); } refreshValueDisplay(); updateApplyAvailability(); }; const getSelectedOption = () => { if (!state.selectedId) { return null; } return Array.from(select.options).find((option) => option.value === state.selectedId) || null; }; const updateModeFromSelection = () => { const option = getSelectedOption(); applyNumericConfig(option); const modeHint = option ? option.dataset.controlMode : 'numeric'; setMode(modeHint); }; const applyTypeFilter = () => { if (!select) { return; } const filterValue = typeFilter ? typeFilter.value : ''; let visibleOptionExists = false; Array.from(select.options).forEach((option, index) => { if (index === 0) { option.hidden = false; option.disabled = false; return; } const optionType = option.dataset.typeKey || ''; const isVisible = !filterValue || optionType === filterValue; option.hidden = !isVisible; option.disabled = !isVisible; if (isVisible) { visibleOptionExists = true; } }); if (select.value) { const selected = select.options[select.selectedIndex]; if (!selected || selected.hidden || selected.disabled) { select.value = ''; state.selectedId = null; clearDisplay(); } } if (typeFilter) { if (visibleOptionExists) { typeFilter.removeAttribute('data-filter-empty'); } else { typeFilter.setAttribute('data-filter-empty', 'true'); } } if (placeholderOption) { placeholderOption.textContent = visibleOptionExists ? placeholderDefaultText : 'No tags for this type'; } updateModeFromSelection(); updateControlAvailability(); }; const setStatus = (message, type = 'info') => { if (!status) { return; } if (state.statusTimeout) { window.clearTimeout(state.statusTimeout); state.statusTimeout = null; } if (!message) { status.hidden = true; status.textContent = ''; status.removeAttribute('data-status-type'); return; } status.hidden = false; status.textContent = message; status.dataset.statusType = type; const timeout = type === 'error' ? 6000 : 2400; state.statusTimeout = window.setTimeout(() => { status.hidden = true; status.textContent = ''; status.removeAttribute('data-status-type'); }, timeout); }; const setBusy = (busy) => { state.busy = busy; select.disabled = busy; updateControlAvailability(); }; const updateControlAvailability = () => { const hasSelection = Boolean(state.selectedId); const numericDisabled = state.busy || !hasSelection || isBooleanMode(); const booleanDisabled = state.busy || !hasSelection || !isBooleanMode(); if (stepInput) { stepInput.disabled = numericDisabled; } if (setInput) { setInput.disabled = numericDisabled; } if (decreaseBtn) { decreaseBtn.disabled = numericDisabled; } if (increaseBtn) { increaseBtn.disabled = numericDisabled; } if (booleanOffBtn) { booleanOffBtn.disabled = booleanDisabled; } if (booleanOnBtn) { booleanOnBtn.disabled = booleanDisabled; } if (refreshBtn) { refreshBtn.disabled = state.busy || !hasSelection; } if (typeFilter) { typeFilter.disabled = state.busy; } updateApplyAvailability(); }; const clearDisplay = () => { state.currentValue = null; state.stagedValue = null; applyNumericConfig(null); if (timestampDisplay) { timestampDisplay.textContent = ''; } if (setInput) { setInput.value = ''; } updateBooleanButtons(null); setMode('numeric'); if (valueDisplay) { valueDisplay.textContent = '—'; } if (pendingIndicator) { pendingIndicator.hidden = true; } }; const applyData = (data) => { if (!data) { clearDisplay(); return; } state.units = data.units || null; if (!state.units && state.datasetUnits) { state.units = state.datasetUnits; } if (data.description) { state.description = data.description; } const resolvedMode = resolveMode(data.control_mode); setMode(resolvedMode); if (timestampDisplay) { timestampDisplay.textContent = data.displayTimestamp || data.timestamp || ''; } if (resolvedMode === 'boolean') { const booleanValue = Number(data.value); state.currentValue = Number.isFinite(booleanValue) ? booleanValue : null; state.stagedValue = null; updateBooleanButtons(state.currentValue); refreshValueDisplay(); updateApplyAvailability(); } else { const numericValue = parseNumericValue(data.value); state.currentValue = numericValue; stageNumericValue(numericValue); } updateControlAvailability(); }; const stageNumericValue = (value, { updateInput = true } = {}) => { if (isBooleanMode()) { return; } if (value === null || value === undefined || value === '') { state.stagedValue = null; if (setInput && updateInput) { setInput.value = ''; } refreshValueDisplay(); updateApplyAvailability(); return; } let numericValue = parseNumericValue(value); if (!Number.isFinite(numericValue)) { state.stagedValue = null; if (setInput && updateInput) { setInput.value = ''; } refreshValueDisplay(); updateApplyAvailability(); return; } numericValue = applyNumericConstraints(numericValue); state.stagedValue = numericValue; if (setInput && updateInput) { setInput.value = Number.isFinite(numericValue) ? numericValue : ''; } refreshValueDisplay(); updateApplyAvailability(); }; const adjustStagedValue = (direction) => { if (!state.selectedId || isBooleanMode() || !stepInput) { return; } const step = parseNumericValue(stepInput.value); if (step === null || step <= 0) { setStatus('Step size must be greater than zero.', 'error'); return; } const base = Number.isFinite(state.stagedValue) ? state.stagedValue : (Number.isFinite(state.currentValue) ? state.currentValue : 0); const newValue = base + direction * Math.abs(step); const constrainedValue = applyNumericConstraints(newValue); stageNumericValue(constrainedValue); if (!state.busy) { setStatus('Adjustments pending. Press Apply to commit.', 'info'); } updateControlAvailability(); }; const buildUrlWithId = (baseUrl, id) => { const url = new URL(baseUrl, window.location.href); url.searchParams.set('id', id); return url.toString(); }; const fetchJson = async (input, init = {}) => { const response = await fetch(input, init); const payload = await response.json().catch(() => ({})); if (!response.ok || !payload.success) { const message = payload.message || `Request failed (HTTP ${response.status})`; if (payload && payload.details) { console.error('Request failed with details:', payload.details); } throw new Error(payload && payload.details ? `${message} — ${payload.details}` : message); } return payload; }; const loadTagValue = async (silent = false) => { if (!state.selectedId) { clearDisplay(); return; } const maxAttempts = 2; const retryDelayMs = 600; setBusy(true); try { for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { if (!silent) { const message = attempt === 1 ? 'Loading current value…' : 'Retrying load after a transient error…'; const tone = attempt === 1 ? 'info' : 'warning'; setStatus(message, tone); } try { const payload = await fetchJson( buildUrlWithId(getUrl, state.selectedId), { headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }, cache: 'no-cache' } ); applyData(payload.data); if (!silent) { setStatus('Latest value loaded.', 'success'); } return; } catch (error) { console.warn(`Attempt ${attempt} to load tag value failed.`, error); if (attempt < maxAttempts) { await delay(retryDelayMs); continue; } console.error(error); throw error; } } } catch (error) { const message = error && error.message ? error.message : 'Failed to read tag value.'; setStatus(message, 'error'); clearDisplay(); } finally { setBusy(false); } }; const sendUpdate = async (body, successMessage = 'Value updated successfully.') => { if (!state.selectedId) { return null; } try { setBusy(true); setStatus('Applying update…', 'info'); const payload = await fetchJson(updateUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest', 'X-CSRF-Token': csrfToken || '' }, body: JSON.stringify({ id: state.selectedId, ...body }) }); applyData(payload.data); let statusTone = 'success'; let statusMessage = successMessage; if (payload.log) { const { success, driver, error, path } = payload.log; if (!success) { statusTone = 'warning'; statusMessage = 'Value updated, but audit log failed. Check with an administrator.'; console.error('Control write audit log failed to persist.', payload.log); } else if (driver && driver !== 'database') { statusTone = 'warning'; statusMessage = 'Value updated. Audit log stored in fallback location.'; console.warn('Control write audit log fallback engaged.', payload.log); if (path) { console.info('Fallback log path:', path); } } else { console.debug('Control write audit logged successfully.'); } if (error) { console.debug('Audit log diagnostics:', error); } } setStatus(statusMessage, statusTone); return payload; } catch (error) { console.error(error); setStatus(error.message, 'error'); return null; } finally { setBusy(false); } }; if (select) { select.addEventListener('change', () => { state.selectedId = select.value || null; if (!state.selectedId) { clearDisplay(); updateControlAvailability(); return; } updateModeFromSelection(); state.currentValue = null; state.stagedValue = null; if (setInput) { setInput.value = ''; } refreshValueDisplay(); updateControlAvailability(); loadTagValue(true); }); } if (typeFilter) { typeFilter.addEventListener('change', () => { applyTypeFilter(); const filterEmpty = typeFilter.getAttribute('data-filter-empty') === 'true'; if (!select.value) { state.selectedId = null; clearDisplay(); if (filterEmpty) { setStatus('No tags available for this type.', 'warning'); } else { setStatus(''); } } else { state.selectedId = select.value; setStatus(''); } updateControlAvailability(); }); } if (decreaseBtn) { decreaseBtn.addEventListener('click', () => { adjustStagedValue(-1); }); } if (increaseBtn) { increaseBtn.addEventListener('click', () => { adjustStagedValue(1); }); } if (refreshBtn) { refreshBtn.addEventListener('click', () => { loadTagValue(); }); } if (setInput) { setInput.addEventListener('input', () => { if (!state.selectedId || isBooleanMode()) { return; } const raw = setInput.value; if (raw === '') { stageNumericValue(null, { updateInput: false }); updateControlAvailability(); return; } const numeric = parseNumericValue(raw); if (numeric === null) { state.stagedValue = null; refreshValueDisplay(); updateApplyAvailability(); updateControlAvailability(); return; } stageNumericValue(numeric, { updateInput: false }); updateControlAvailability(); }); } if (applyButton) { applyButton.addEventListener('click', () => { if (!isApplyReady()) { setStatus('Provide a numeric value to apply.', 'error'); return; } const target = state.stagedValue; sendUpdate({ value: target }, 'Value applied successfully.'); }); } if (booleanOffBtn) { booleanOffBtn.addEventListener('click', () => { if (!state.selectedId || !isBooleanMode()) { return; } if (valuesEqual(state.currentValue, 0)) { setStatus('Switch is already Off.', 'info'); return; } sendUpdate({ value: 0 }, 'Switch turned off.'); }); } if (booleanOnBtn) { booleanOnBtn.addEventListener('click', () => { if (!state.selectedId || !isBooleanMode()) { return; } if (valuesEqual(state.currentValue, 1)) { setStatus('Switch is already On.', 'info'); return; } sendUpdate({ value: 1 }, 'Switch turned on.'); }); } applyTypeFilter(); updateControlAvailability(); if (state.selectedId) { updateModeFromSelection(); loadTagValue(true); } }); })();