921 lines
26 KiB
JavaScript
921 lines
26 KiB
JavaScript
(() => {
|
|
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);
|
|
}
|
|
});
|
|
})();
|