From 58bc3a94ac9099185363e7212939c50c9024621a Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sun, 13 Sep 2026 11:27:39 -0600 Subject: [PATCH 01/19] Load selected server settings automatically --- pom.xml | 2 +- .../domain/ConfigurationOperations.java | 11 +- .../control/domain/InMemoryNodeRegistry.java | 1 + .../protocol/ManagedConfiguration.java | 5 +- src/main/resources/web/app.js | 175 ++++++++++++++---- src/main/resources/web/index.html | 11 +- .../domain/ConfigurationOperationsTest.java | 6 + .../control/http/ControlHttpServerTest.java | 26 ++- 8 files changed, 187 insertions(+), 50 deletions(-) diff --git a/pom.xml b/pom.xml index 00221b4b..09b8c353 100644 --- a/pom.xml +++ b/pom.xml @@ -47,7 +47,7 @@ org.apache.maven.pluginsmaven-shade-plugin3.6.0 - packageshadetrueall + packageshadetrueallfalse diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java index 13adbd85..0b04262e 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java @@ -33,6 +33,7 @@ public final class ConfigurationOperations implements AutoCloseable { public static final String VOTE_SITES_SYNC_CAPABILITY = "config.vote-sites-sync.v1"; public static final String TRANSPORT_TEST_CAPABILITY = "config.transport-test.v1"; public static final String PROXY_METHOD_CAPABILITY = "config.proxy-method.v1"; + public static final String PROXY_METHOD_HTTP_CAPABILITY = "config.proxy-method.v2"; private static final int MAX_OPERATIONS = 1000; private static final int MAX_LISTED_OPERATIONS = 100; private static final int MAX_FILE_OPERATIONS = 16; @@ -318,8 +319,16 @@ private boolean deferProxyMethodApply(StoredOperation operation, NodeStatus node "Proxy topology changed after approval; preview again", "TOPOLOGY_CHANGED"); return true; } + String causes = backends.stream().filter(backendId -> operation.results.get(backendId) == null + || !operation.results.get(backendId).success()) + .map(backendId -> { + ConfigurationTaskResult failure = operation.results.get(backendId); + return failure == null ? backendId + " RESULT_UNAVAILABLE" + : backendId + " " + failure.code() + ": " + failure.message(); + }) + .collect(java.util.stream.Collectors.joining("; ")); automaticCancellation(operation, node.nodeId(), sessionId(node), "DEPENDENCY_FAILED", - "A backend failed the proxy method apply", "BACKEND_APPLY_FAILED"); + truncateUtf8("Proxy not applied because " + causes, 500), "BACKEND_APPLY_FAILED"); return true; } diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java b/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java index 176c010f..441e45f4 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java @@ -30,6 +30,7 @@ public final class InMemoryNodeRegistry implements NodeRegistry { ConfigurationOperations.PROXY_FILE_CAPABILITY, ConfigurationOperations.QUICK_SETUP_CAPABILITY, ConfigurationOperations.VOTE_SITES_SYNC_CAPABILITY, ConfigurationOperations.TRANSPORT_TEST_CAPABILITY, ConfigurationOperations.PROXY_METHOD_CAPABILITY, + ConfigurationOperations.PROXY_METHOD_HTTP_CAPABILITY, "config.file-comments.v1", InspectionQuery.CAPABILITY); private static final Pattern ID = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,63}"); private static final Pattern CAPABILITY = Pattern.compile("[a-z][a-z0-9.-]{0,63}"); diff --git a/src/main/java/com/bencodez/votingplugin/control/protocol/ManagedConfiguration.java b/src/main/java/com/bencodez/votingplugin/control/protocol/ManagedConfiguration.java index 407ed31c..210655fa 100644 --- a/src/main/java/com/bencodez/votingplugin/control/protocol/ManagedConfiguration.java +++ b/src/main/java/com/bencodez/votingplugin/control/protocol/ManagedConfiguration.java @@ -112,7 +112,7 @@ public void validateProposal() { throw new IllegalArgumentException("communication test requires one valid server"); } if (QUICK_SETUP.equals(domain) && PROXY_METHOD.equals(preset) - && (options.size() != 1 || !List.of("PLUGINMESSAGING", "REDIS", "MQTT", "SOCKETS", "MYSQL") + && (options.size() != 1 || !List.of("PLUGINMESSAGING", "REDIS", "MQTT", "SOCKETS", "MYSQL", "HTTP") .contains(options.get("method")))) { throw new IllegalArgumentException("proxy method requires one supported method"); } @@ -129,7 +129,8 @@ public String capability() { case QUICK_SETUP -> VOTE_SITES_SYNC.equals(preset) ? "config.vote-sites-sync.v1" : COMMUNICATION_TEST.equals(preset) ? "config.transport-test.v1" : PROXY_METHOD.equals(preset) - ? "config.proxy-method.v1" : "config.quick-setup.v1"; + ? "HTTP".equals(options.get("method")) ? "config.proxy-method.v2" : "config.proxy-method.v1" + : "config.quick-setup.v1"; default -> throw new IllegalStateException("unsupported configuration domain"); }; } diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index 1b2500f9..a06a0fec 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -98,6 +98,7 @@ const quickExtraCheck = document.querySelector('#quick-extra-check'); const quickCountFake = document.querySelector('#quick-count-fake'); const quickHideSiteWarning = document.querySelector('#quick-hide-site-warning'); const quickDisableUpdates = document.querySelector('#quick-disable-updates'); +const quickPartyEnabled = document.querySelector('#quick-party-enabled'); const quickPartyVotes = document.querySelector('#quick-party-votes'); const quickPartyCommand = document.querySelector('#quick-party-command'); const quickPartyBroadcast = document.querySelector('#quick-party-broadcast'); @@ -274,6 +275,7 @@ let enrollmentRefreshResolve = null; let enrollmentMutationInFlight = false; let configurationOperationsInFlight = 0; let proxyMethodWorkflowInFlight = false; +let voteSiteReadTimer = null; const FILE_READ_CACHE_TTL_MS = 30_000; const MAX_FILE_READ_CACHE_ENTRIES = 12; const MAX_OPERATION_HISTORY = 50; @@ -356,6 +358,7 @@ const SETTINGS_SCHEMA = Object.freeze([ {key: 'BungeeMethod', file: 'BungeeSettings.yml', type: 'enum', defaultValue: 'PLUGINMESSAGING', effect: 'Select the proxy transport.'}, {key: 'VoteSites..Enabled', file: 'VoteSites.yml', type: 'boolean', defaultValue: 'true', effect: 'Allow a configured site to resolve and reward votes.'}, {key: 'VoteSites..ServiceSite', file: 'VoteSites.yml', type: 'text ≤200', defaultValue: '', effect: 'Match the service name supplied by the vote listener.'}, + {key: 'VoteParty.Enabled', file: 'SpecialRewards.yml', type: 'boolean', defaultValue: 'false', effect: 'Enable or disable Vote Party without changing other party settings.'}, {key: 'VoteParty.VotesRequired', file: 'SpecialRewards.yml', type: 'integer 1–100000', defaultValue: '20', effect: 'Number of votes required to trigger a vote party.'} ]); @@ -1169,7 +1172,7 @@ function currentProfileValues() { processRewards: quickProcessRewards.checked, autoSites: quickAutoSites.checked, extraCheck: quickExtraCheck.checked, countFake: quickCountFake.checked, hideWarning: quickHideSiteWarning.checked, disableUpdates: quickDisableUpdates.checked, - partyVotes: quickPartyVotes.value, partyCommand: quickPartyCommand.value, + partyEnabled: quickPartyEnabled.checked, partyVotes: quickPartyVotes.value, partyCommand: quickPartyCommand.value, partyBroadcast: quickPartyBroadcast.value, partyAll: quickPartyAll.checked, partyOnline: quickPartyOnline.checked, autoSitesOnly: quickAutoSitesOnly.checked, voteLogging: quickVoteLoggingEnabled.checked, voteLoggingDays: quickVoteLoggingDays.value, voteLoggingMainMysql: quickVoteLoggingMainMysql.checked, @@ -1321,6 +1324,7 @@ function friendlyCapability(capability) { 'config.vote-sites-sync.v1': 'VoteSites sync', 'config.transport-test.v1': 'Communication test', 'config.proxy-method.v1': 'Proxy method', + 'config.proxy-method.v2': 'Proxy method · HTTP', 'config.quick-setup.v1': 'Setup assistant', 'config.proxy-routing.v1': 'Proxy routing', 'data.inspect.v1': 'Read-only data inspection' @@ -1528,6 +1532,9 @@ function resetFileEditorForSelection(message) { lastFileReadOperation = null; approvedFilePreview = null; updateEditorPosition(); + configurationContent.disabled = false; + configurationContent.removeAttribute('aria-busy'); + readFileConfiguration.hidden = true; text(fileOperationStatus, message); } @@ -1599,8 +1606,7 @@ async function autoLoadTab(tab) { return; } if (tab === 'configurations') { - const yamlVisible = configViewPanels.some(panel => panel.dataset.configPanel === 'yaml' && !panel.hidden); - if (!yamlVisible || configurationDirty || configurationContentPresent || !fileTargetsForSelection().length) return; + if (configurationDirty || configurationContentPresent || !fileTargetsForSelection().length) return; autoLoadInFlight.add(tab); try { await loadFileConfiguration(true); } finally { finishAutoLoad(tab); } return; @@ -1613,10 +1619,15 @@ async function autoLoadTab(tab) { } finally { finishAutoLoad(tab); } return; } - if (tab === 'quick-setup' && quickPresetReadable() && !loadedQuickSetup - && !approvedQuickPreview && !configurationOperationsInFlight) { + if (tab === 'quick-setup' && !approvedQuickPreview && !configurationOperationsInFlight + && (autoSitesState.textContent === 'Not loaded' || voteLoggingState.textContent === 'Not loaded' + || quickPresetReadable() && !loadedQuickSetup)) { autoLoadInFlight.add(tab); - try { await loadQuickSetupValues(true); } finally { finishAutoLoad(tab); } + try { + if (autoSitesState.textContent === 'Not loaded') await loadDedicatedSetup('auto-create-vote-sites', true); + if (voteLoggingState.textContent === 'Not loaded') await loadDedicatedSetup('vote-logging', true); + if (quickPresetReadable() && !loadedQuickSetup) await loadQuickSetupValues(true); + } finally { finishAutoLoad(tab); } return; } if (tab === 'data' && inspectionCapableNode() && !inspectionInFlight && !lastOverview) { @@ -1821,7 +1832,7 @@ function normalizeDashboardOverview(value) { if (!platforms.has(result.platform.toUpperCase()) || !dataStorages.has(result.dataStorage.toUpperCase())) { incomplete = true; } - const proxyMethods = new Set(['PLUGINMESSAGING', 'REDIS', 'MQTT', 'MYSQL', 'SOCKETS']); + const proxyMethods = new Set(['PLUGINMESSAGING', 'REDIS', 'MQTT', 'MYSQL', 'SOCKETS', 'HTTP']); if (result.proxyMode === true && !proxyMethods.has(result.proxyMethod.toUpperCase())) incomplete = true; return {result, incomplete}; } @@ -2533,22 +2544,26 @@ function proxyMethodCandidates() { node.acceptedCapabilities.includes('config.proxy-method.v1')); } -function proxyMethodNetworkFor(items, truncatedNodeIds, proxyId) { +function proxyMethodCapabilityFor(method) { + return method === 'HTTP' ? 'config.proxy-method.v2' : 'config.proxy-method.v1'; +} + +function proxyMethodNetworkFor(items, truncatedNodeIds, proxyId, capability = 'config.proxy-method.v1') { const index = new Map(items.map(node => [node.nodeId, node])); const proxy = index.get(proxyId); - const proxyReady = Boolean(proxy?.online && proxy.acceptedCapabilities.includes('config.proxy-method.v1')); + const proxyReady = Boolean(proxy?.online && proxy.acceptedCapabilities.includes(capability)); const reported = Array.isArray(proxy?.backends) ? proxy.backends : []; const backends = reported.map(backend => index.get(backend.backendId)).filter(Boolean); const unavailable = reported.filter(backend => { const node = index.get(backend.backendId); - return !node || !isBackend(node) || !node.online || !node.acceptedCapabilities.includes('config.proxy-method.v1'); + return !node || !isBackend(node) || !node.online || !node.acceptedCapabilities.includes(capability); }); return {proxy, proxyReady, reported, backends, unavailable, topologyComplete: !truncatedNodeIds.has(proxyId), nodeIds: proxy ? [proxy.nodeId, ...backends.map(node => node.nodeId)] : []}; } -function proxyMethodNetwork() { - return proxyMethodNetworkFor(allNodeItems, backendTopologyTruncatedNodeIds, proxyMethodProxyId); +function proxyMethodNetwork(capability = 'config.proxy-method.v1') { + return proxyMethodNetworkFor(allNodeItems, backendTopologyTruncatedNodeIds, proxyMethodProxyId, capability); } function proxyMethodNetworkSignature(network) { @@ -2590,8 +2605,11 @@ function renderProxyMethod() { proxyMethodCurrent.className = `pill ${proxyMethodCurrentValue ? 'online' : 'neutral'}`; proxyMethodButtons.forEach(button => { const active = button.dataset.proxyMethod === proxyMethodCurrentValue; + const methodNetwork = proxyMethodNetwork(proxyMethodCapabilityFor(button.dataset.proxyMethod)); button.classList.toggle('active', active); button.setAttribute('aria-pressed', String(active)); + button.disabled = !methodNetwork.proxyReady || !methodNetwork.topologyComplete || methodNetwork.reported.length === 0 + || methodNetwork.nodeIds.length > MAX_OPERATION_TARGETS || methodNetwork.unavailable.length > 0; }); } @@ -2738,6 +2756,8 @@ function resetDedicatedSetupValues() { text(voteLoggingState, 'Not loaded'); autoSitesState.className = 'pill neutral'; voteLoggingState.className = 'pill neutral'; + loadAutoSites.hidden = true; + loadVoteLogging.hidden = true; } function resetServerContextValues(reason, preserveDirtyDrafts = false) { @@ -2777,6 +2797,7 @@ function resetServerContextValues(reason, preserveDirtyDrafts = false) { text(autoSitesStatus, reason); text(voteLoggingStatus, reason); loadedQuickSetup = null; + readQuickSetup.hidden = true; resetServerConfigurationForms(reason, preserveDirtyDrafts); const preset = quickPreset.value; quickSetupForm.reset(); @@ -2830,9 +2851,11 @@ function updateConfigurationButtons(busy = configurationOperationsInFlight > 0 | applyQuickSetup.disabled = !quickReady || !approvedQuickPreview; runTransportTest.disabled = !authenticated || !transportTestProxyId || !transportTestBackendId || busy; const methodNetwork = proxyMethodNetwork(); - const methodReady = authenticated && methodNetwork.proxyReady && methodNetwork.topologyComplete && methodNetwork.reported.length > 0 && - methodNetwork.nodeIds.length <= MAX_OPERATION_TARGETS && methodNetwork.unavailable.length === 0 && !busy; - proxyMethodButtons.forEach(button => { button.disabled = !methodReady; }); + proxyMethodButtons.forEach(button => { + const network = proxyMethodNetwork(proxyMethodCapabilityFor(button.dataset.proxyMethod)); + button.disabled = !authenticated || !network.proxyReady || !network.topologyComplete || network.reported.length === 0 + || network.nodeIds.length > MAX_OPERATION_TARGETS || network.unavailable.length > 0 || busy; + }); readProxyMethod.disabled = !authenticated || !methodNetwork.proxyReady || busy; } @@ -2889,7 +2912,7 @@ function updateQuickFields() { }); quickName.closest('.quick-fields').hidden = !['proxy-backend', 'vote-site', 'easy-reward'].includes(quickPreset.value); const sync = quickPreset.value === 'sync-vote-sites'; - readQuickSetup.hidden = !quickPresetReadable(); + readQuickSetup.hidden = true; previewQuickSetup.textContent = sync ? 'Read source and preview sync' : 'Preview changes'; applyQuickSetup.textContent = sync ? 'Approve and sync' : 'Approve and apply'; updateConfigurationButtons(); @@ -3111,6 +3134,20 @@ function voteLoggingRestartRequired(nodeId = selectedServerId) { return true; } +function configurationFailureLabel(code) { + const labels = { + TRANSPORT_FAILED: 'Transport failure', CAPABILITY_LOST: 'Unsupported capability', + UNSUPPORTED_CAPABILITY: 'Unsupported capability', INVALID_CONFIGURATION: 'Invalid configuration', + READ_FAILED: 'Configuration read failed', CONFIGURATION_MISSING: 'Configuration missing', + CONFIGURATION_UNREADABLE: 'Configuration unreadable', CONFIGURATION_TOO_LARGE: 'Configuration too large', + CONFIGURATION_INVALID_ENCODING: 'Invalid configuration encoding', CONFIGURATION_UNSAFE: 'Unsafe configuration path', + WRITE_FAILED: 'Configuration write failed', + RELOAD_FAILED: 'Configuration reload failed', RESTART_REQUIRED: 'Restart required', + DEPENDENCY_FAILED: 'Dependency failure', DEPENDENCY_CHANGED: 'Dependency changed' + }; + return labels[code] || code; +} + function operationSummary(operation) { const lines = [`${operation.type} · ${operation.state} · ${operation.operationId}`]; const voteLoggingOperation = operation.configuration?.preset === 'vote-logging'; @@ -3134,7 +3171,7 @@ function operationSummary(operation) { ? 'configuration saved; proxy restart required' : result?.reloaded ? 'saved and reloaded' : 'applied'; lines.push(`${result?.success ? '✓' : result ? '✗' : '…'} ${node}: ${result - ? `${result.success ? successLabel : result.code} — ${result.message}` : state.toLowerCase()}`); + ? `${result.success ? successLabel : configurationFailureLabel(result.code)} — ${result.message}` : state.toLowerCase()}`); if (result?.changes?.length) result.changes.forEach(change => lines.push(` ${change}`)); if (result?.rolledBack) lines.push(' NOT SAVED — the previous file was restored because reload failed'); }); @@ -3672,6 +3709,7 @@ applyConfiguration.addEventListener('click', async () => { if (operation.state === 'SUCCEEDED' && submittedProposal === currentProposal) { routingDirty = false; routingDraftNodeId = ''; + await loadProxyRouting(true); } } catch (error) { text(operationStatus, error.message); } }); @@ -3695,7 +3733,7 @@ async function loadFileConfiguration(automatic = false) { const selectedReadNodeId = selectedNode?.online && selectedNode.acceptedCapabilities.includes(selectedFileCapability(selectedFile)) && (selectedFile === 'bungeeconfig.yml' ? isProxy(selectedNode) : isBackend(selectedNode)) ? selectedServerId : ''; if (!selectedReadNodeId) { - if (!automatic) text(fileOperationStatus, 'Choose a connected node that supports this configuration file.'); + text(fileOperationStatus, 'Choose a connected node that supports this configuration file.'); return; } const cacheKey = `${selectedServerId}|${selectedNode?.sessionId || ''}|${selectedFile}`; @@ -3713,8 +3751,15 @@ async function loadFileConfiguration(automatic = false) { inputGeneration++; updateConfigurationButtons(); updateExtendedButtons(); + readFileConfiguration.hidden = true; return; } + configurationContent.value = ''; + configurationContentPresent = false; + configurationContent.disabled = true; + configurationContent.setAttribute('aria-busy', 'true'); + readFileConfiguration.hidden = true; + text(fileOperationStatus, `Loading ${selectedFile} from ${selectedReadNodeId}…`); try { const operation = await startConfigurationOperation('/api/v1/configuration/read', { nodeIds: [selectedReadNodeId], @@ -3738,9 +3783,29 @@ async function loadFileConfiguration(automatic = false) { inputGeneration++; updateConfigurationButtons(); updateExtendedButtons(); + readFileConfiguration.hidden = true; + } else if (readAuthenticationGeneration === authenticationGeneration && readInputGeneration === inputGeneration + && selectedFile === configurationFile.value && selectedReadNodeId === selectedServerId) { + throw new Error(contentResult?.message || `The ${selectedFile} read did not return editable content.`); } } catch (error) { - if (!automatic) text(fileOperationStatus, error.message); + if (authenticated && readAuthenticationGeneration === authenticationGeneration + && readInputGeneration === inputGeneration && selectedFile === configurationFile.value + && selectedReadNodeId === selectedServerId + && selectedNode.sessionId === nodeIndex.get(selectedReadNodeId)?.sessionId) { + configurationContent.value = ''; + configurationContentPresent = false; + text(fileOperationStatus, `Could not load ${selectedFile}: ${error.message}`); + readFileConfiguration.hidden = false; + } + } finally { + if (authenticated && readAuthenticationGeneration === authenticationGeneration + && selectedFile === configurationFile.value && selectedReadNodeId === selectedServerId + && selectedNode.sessionId === nodeIndex.get(selectedReadNodeId)?.sessionId) { + configurationContent.disabled = false; + configurationContent.removeAttribute('aria-busy'); + updateConfigurationButtons(); + } } } @@ -3787,24 +3852,29 @@ applyFileConfiguration.addEventListener('click', async () => { || !window.confirm(`Apply this exact ${configurationFile.value} preview to ${fileTargetDescription()}?`)) return; const approval = approvedFilePreview; approvedFilePreview = null; - const submittedFile = JSON.stringify({content: configurationContent.value, fileName: approval.fileName, - nodeIds: approval.nodeIds, sessions: approval.nodeIds.map(nodeId => approval.sessions.get(nodeId))}); + // Applying claims one generation; the successful-read invalidation claims the next. + const expectedApplyGeneration = inputGeneration + 2; try { const operation = await startConfigurationOperation('/api/v1/configuration/apply', { previewOperationId: approval.operationId, approvalToken: approval.approvalToken }, fileOperationStatus); const currentFileTargets = fileTargetsForSelection(configurationFile.value); - const currentFile = JSON.stringify({content: configurationContent.value, fileName: configurationFile.value, - nodeIds: currentFileTargets, sessions: currentFileTargets.map(nodeId => nodeIndex.get(nodeId)?.sessionId)}); - if (operation.state === 'SUCCEEDED' && submittedFile === currentFile) { - text(fileOperationStatus, operationSummary(operation)); + const submittedContextStillCurrent = inputGeneration === expectedApplyGeneration + && approval.fileName === configurationFile.value + && currentFileTargets.length === approval.nodeIds.length + && approval.nodeIds.every(nodeId => currentFileTargets.includes(nodeId) + && approval.sessions.get(nodeId) === nodeIndex.get(nodeId)?.sessionId); + if (operation.state === 'SUCCEEDED' && submittedContextStillCurrent) { fileReadCache.clear(); lastFileReadOperation = null; configurationDirty = false; configurationDraftNodeId = ''; configurationDraftSessionId = ''; configurationDraftFileName = ''; + configurationContent.value = ''; + configurationContentPresent = false; updateExtendedButtons(); + await loadFileConfiguration(true); } else if (operation.state === 'SUCCEEDED') { text(fileOperationStatus, `${operationSummary(operation)}\nThe apply completed, but newer unsaved file edits remain. Preview again before applying them.`); } else { @@ -3833,7 +3903,8 @@ function quickOptions() { disableNoServiceSiteMessage: String(quickHideSiteWarning.checked), disableUpdateChecking: String(quickDisableUpdates.checked) }; - return {votesRequired: quickPartyVotes.value, command: quickPartyCommand.value.trim(), + return {enabled: String(quickPartyEnabled.checked), votesRequired: quickPartyVotes.value, + command: quickPartyCommand.value.trim(), broadcast: quickPartyBroadcast.value.trim(), giveAllPlayers: String(quickPartyAll.checked), onlineOnly: String(quickPartyOnline.checked)}; } @@ -3869,6 +3940,7 @@ function populateQuickState(options) { quickVoteLoggingDays.value = options.purgeDays || '30'; quickVoteLoggingMainMysql.checked = options.useMainMySQL !== 'false'; } else if (quickPreset.value === 'vote-party') { + quickPartyEnabled.checked = options.enabled === 'true'; quickPartyVotes.value = options.votesRequired || '20'; quickPartyBroadcast.value = options.broadcast || ''; quickPartyAll.checked = options.giveAllPlayers === 'true'; @@ -3886,6 +3958,8 @@ async function loadQuickSetupValues(automatic = false) { const sessionId = nodeIndex.get(nodeId)?.sessionId; const selector = JSON.stringify(quickReadOptions()); const generation = inputGeneration; + readQuickSetup.hidden = true; + text(quickOperationStatus, `Loading current ${preset} settings from ${nodeId}…`); try { const operation = await startConfigurationOperation('/api/v1/configuration/read', { nodeIds: [selectedServerId], @@ -3917,9 +3991,17 @@ async function loadQuickSetupValues(automatic = false) { : preset === 'vote-party' && Number(result.configuration.options.rewardCommandCount || 0) > 0 ? ` ${result.configuration.options.rewardCommandCount} existing reward command(s) will be preserved.` : ''; text(quickOperationStatus, `Current values loaded from ${Object.keys(operation.results).find(id => operation.results[id] === result)}.${suffix}`); + readQuickSetup.hidden = true; updateConfigurationButtons(); } catch (error) { - if (!automatic) text(quickOperationStatus, error.message); + if (authenticated && generation === inputGeneration && preset === quickPreset.value + && nodeId === selectedServerId && sessionId === nodeIndex.get(nodeId)?.sessionId + && selector === JSON.stringify(quickReadOptions())) { + loadedQuickSetup = null; + text(quickOperationStatus, `Could not load current ${preset} settings: ${error.message}`); + readQuickSetup.hidden = false; + updateConfigurationButtons(); + } } } @@ -3991,6 +4073,10 @@ applyQuickSetup.addEventListener('click', async () => { text(quickOperationStatus, operation.state === 'SUCCEEDED' && submittedQuickSetup !== currentQuickSetup ? `${operationSummary(operation)}\nThe apply completed, but newer guided setup edits remain. Preview again before applying them.` : operationSummary(operation)); + if (operation.state === 'SUCCEEDED' && submittedQuickSetup === currentQuickSetup && !sync) { + loadedQuickSetup = null; + await loadQuickSetupValues(true); + } } catch (error) { text(quickOperationStatus, error.message); } }); @@ -4083,7 +4169,7 @@ proxyMethodProxy.addEventListener('change', () => { proxyMethodButtons.forEach(button => button.addEventListener('click', async () => { const method = button.dataset.proxyMethod; - const network = proxyMethodNetwork(); + const network = proxyMethodNetwork(proxyMethodCapabilityFor(method)); if (!network.proxyReady || !network.topologyComplete || network.reported.length === 0 || network.nodeIds.length > MAX_OPERATION_TARGETS || network.unavailable.length > 0 || proxyMethodWorkflowInFlight) return; proxyMethodWorkflowInFlight = true; @@ -4098,7 +4184,7 @@ proxyMethodButtons.forEach(button => button.addEventListener('click', async () = 'Backends reload their communication handler; the proxy replaces its runtime after Control records the result.')) return; const refreshedRegistry = await loadAllNodes(); const refreshedNetwork = proxyMethodNetworkFor(refreshedRegistry.items, refreshedRegistry.truncatedNodeIds, - proxyMethodProxyId); + proxyMethodProxyId, proxyMethodCapabilityFor(method)); if (proxyMethodProxyId !== network.proxy.nodeId || refreshedNetwork.proxy?.sessionId !== network.proxy.sessionId || proxyMethodNetworkSignature(refreshedNetwork) !== proxyMethodNetworkSignature(network) || @@ -4143,16 +4229,18 @@ function validatedPurgeDays(field) { function dedicatedSetupElements(preset) { return preset === 'auto-create-vote-sites' - ? {status: autoSitesStatus, state: autoSitesState} - : {status: voteLoggingStatus, state: voteLoggingState}; + ? {status: autoSitesStatus, state: autoSitesState, retry: loadAutoSites} + : {status: voteLoggingStatus, state: voteLoggingState, retry: loadVoteLogging}; } -async function loadDedicatedSetup(preset) { +async function loadDedicatedSetup(preset, automatic = false) { dedicatedSetupApprovals.delete(preset); const elements = dedicatedSetupElements(preset); const requestNodeId = selectedServerId; const requestSessionId = nodeIndex.get(requestNodeId)?.sessionId; const requestGeneration = inputGeneration; + elements.retry.hidden = true; + text(elements.status, `Loading current ${preset} settings from ${requestNodeId}…`); try { const operation = await startConfigurationOperation('/api/v1/configuration/read', { nodeIds: [selectedServerId], configuration: {domain: 'quick-setup', preset, options: {}} @@ -4173,7 +4261,14 @@ async function loadDedicatedSetup(preset) { text(voteLoggingState, voteLoggingEnabled.checked ? 'Enabled on primary' : 'Disabled on primary'); } elements.state.className = `pill ${options.enabled === 'true' ? 'online' : 'neutral'}`; - } catch (error) { text(elements.status, error.message); } + text(elements.status, operationSummary(operation)); + } catch (error) { + if (requestNodeId === selectedServerId && requestSessionId === nodeIndex.get(requestNodeId)?.sessionId + && requestGeneration === inputGeneration) { + text(elements.status, `Could not load current ${preset} settings: ${error.message}`); + elements.retry.hidden = false; + } + } updateExtendedButtons(); } @@ -4224,6 +4319,7 @@ async function applyDedicatedSetup(preset) { text(elements.state, 'Saved; restart required'); elements.state.className = 'pill neutral'; } + await loadDedicatedSetup(preset, true); lastOverview = null; } else if (operation.state === 'SUCCEEDED') { text(elements.status, `${operationSummary(operation)}\nThe apply completed, but newer setup edits remain. Preview again before applying them.`); @@ -4805,7 +4901,8 @@ loadProfile.addEventListener('click', () => { assign(quickMessage, profile.playerMessage, 500); quickProcessRewards.checked = Boolean(profile.processRewards); quickAutoSites.checked = Boolean(profile.autoSites); quickExtraCheck.checked = Boolean(profile.extraCheck); quickCountFake.checked = Boolean(profile.countFake); quickHideSiteWarning.checked = Boolean(profile.hideWarning); - quickDisableUpdates.checked = Boolean(profile.disableUpdates); assign(quickPartyVotes, profile.partyVotes, 6); + quickDisableUpdates.checked = Boolean(profile.disableUpdates); quickPartyEnabled.checked = Boolean(profile.partyEnabled); + assign(quickPartyVotes, profile.partyVotes, 6); assign(quickPartyCommand, profile.partyCommand, 500); assign(quickPartyBroadcast, profile.partyBroadcast, 500); quickPartyAll.checked = Boolean(profile.partyAll); quickPartyOnline.checked = Boolean(profile.partyOnline); quickAutoSitesOnly.checked = Boolean(profile.autoSitesOnly); quickVoteLoggingEnabled.checked = Boolean(profile.voteLogging); @@ -4841,12 +4938,19 @@ clearOperationHistory.addEventListener('click', loadOperationHistory); [quickName, quickMethod, quickSiteDisplayName, quickService, quickUrl, quickDelay, quickSitePriority, quickSiteMaterial, quickSiteEnabled, quickSiteHidden, quickRewardScope, quickCommand, quickMessage, quickProcessRewards, quickAutoSites, quickExtraCheck, quickCountFake, - quickHideSiteWarning, quickDisableUpdates, quickPartyVotes, quickPartyCommand, quickPartyBroadcast, + quickHideSiteWarning, quickDisableUpdates, quickPartyEnabled, quickPartyVotes, quickPartyCommand, quickPartyBroadcast, quickPartyAll, quickPartyOnline, quickAutoSitesOnly, quickVoteLoggingEnabled, quickVoteLoggingDays, quickVoteLoggingMainMysql].forEach(field => field.addEventListener('input', clearApprovals)); quickName.addEventListener('input', () => { if (pendingDetectedVoteSite && pendingDetectedVoteSite.key !== quickName.value.trim()) pendingDetectedVoteSite = null; updateQuickFields(); + if (voteSiteReadTimer != null) window.clearTimeout(voteSiteReadTimer); + if (quickPreset.value === 'vote-site' && quickPresetReadable()) { + voteSiteReadTimer = window.setTimeout(() => { + voteSiteReadTimer = null; + void autoLoadTab('quick-setup'); + }, 300); + } }); configurationContent.addEventListener('input', () => { if (!configurationDirty) { @@ -4885,6 +4989,7 @@ quickPreset.addEventListener('input', () => { ? 'Enter the vote-site key, then load its current values before previewing.' : 'Load the current values from the primary server before previewing changes.'); } + void autoLoadTab('quick-setup'); }); serverPicker.addEventListener('change', () => selectPrimaryServer(serverPicker.value)); tabButtons.forEach(button => button.addEventListener('click', () => { diff --git a/src/main/resources/web/index.html b/src/main/resources/web/index.html index bcf9809f..7c4a1e5a 100644 --- a/src/main/resources/web/index.html +++ b/src/main/resources/web/index.html @@ -228,6 +228,7 @@

Proxy method

+
@@ -327,7 +328,7 @@

Full VotingPlugin configuration

@@ -444,6 +445,7 @@

Setup

@@ -511,7 +514,7 @@

Setup

Existing reward sections and commands are preserved. A new command is appended once.

- +
diff --git a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java index 3838dac7..8d63481c 100644 --- a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java @@ -875,6 +875,11 @@ class ConfigurationOperationsTest { assertThrows(IllegalArgumentException.class, () -> new ManagedConfiguration(ManagedConfiguration.QUICK_SETUP, null, List.of(), null, null, ManagedConfiguration.PROXY_METHOD, Map.of("method", "UNKNOWN")).validateProposal()); + + ManagedConfiguration http = new ManagedConfiguration(ManagedConfiguration.QUICK_SETUP, null, List.of(), + null, null, ManagedConfiguration.PROXY_METHOD, Map.of("method", "HTTP")); + http.validateProposal(); + assertEquals(ConfigurationOperations.PROXY_METHOD_HTTP_CAPABILITY, http.capability()); } @Test void proxyMethodApplyKeepsEvictedBackendsAsFailedDependencies() throws Exception { @@ -892,6 +897,7 @@ class ConfigurationOperationsTest { assertEquals("COMPLETED_WITH_ERRORS", result.state()); assertEquals("CAPABILITY_LOST", result.results().get("lobby").code()); assertEquals("DEPENDENCY_FAILED", result.results().get("proxy-a").code()); + assertTrue(result.results().get("proxy-a").message().contains("lobby CAPABILITY_LOST")); } @Test void proxyMethodApplyDoesNotCancelAnOfflineBackendWithAnActiveLease() throws Exception { diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java index fc9141c0..9b58334d 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -68,7 +68,9 @@ class ControlHttpServerTest { assertTrue(web.body().contains("Comment support unknown")); assertTrue(web.body().contains("Sync site definitions across backends")); assertTrue(web.body().contains("Target-only sites and every reward section stay local")); - assertTrue(web.body().contains("Load current values")); + assertFalse(web.body().contains("Load current values")); + assertTrue(web.body().contains("Retry read")); + assertTrue(web.body().contains("id=\"quick-party-enabled\"")); assertTrue(web.headers().firstValue("Content-Security-Policy").orElseThrow().contains("default-src 'self'")); HttpResponse script = get("/app.js", null); assertEquals(200, script.statusCode()); @@ -118,6 +120,16 @@ class ControlHttpServerTest { assertTrue(script.body().contains("refreshedNetwork.proxy?.sessionId !== network.proxy.sessionId")); assertTrue(script.body().contains("if (approvedQuickPreview?.workflow === 'sync-vote-sites') approvedQuickPreview = null;")); assertTrue(script.body().contains("if (quickPreset.value !== 'sync-vote-sites') return;")); + assertTrue(script.body().contains("await loadFileConfiguration(true);"), + "Successful file applies must refresh confirmed current state."); + assertTrue(script.body().contains("await loadQuickSetupValues(true);"), + "Successful guided applies must refresh confirmed current state."); + assertTrue(script.body().contains("configurationContent.setAttribute('aria-busy', 'true')")); + assertTrue(script.body().contains("readFileConfiguration.hidden = false;")); + assertTrue(script.body().contains("readQuickSetup.hidden = false;")); + assertTrue(script.body().contains("enabled: String(quickPartyEnabled.checked)")); + assertTrue(script.body().contains("quickPartyEnabled.checked = options.enabled === 'true'")); + assertTrue(script.body().contains("config.proxy-method.v2")); assertTrue(web.body().contains("Add a simple vote reward")); assertTrue(web.body().contains("First-run setup")); assertTrue(web.body().contains("Node enrollment")); @@ -253,10 +265,10 @@ class ControlHttpServerTest { "Activity refreshes must invalidate cached health after observing an external successful apply."); assertTrue(script.body().contains("if (applied) {\n invalidateConfigurationReads();"), "Locally completed applies must use the same cache invalidation path."); - assertTrue(script.body().contains("const submittedFile = JSON.stringify({content: configurationContent.value, fileName: approval.fileName,")); - assertTrue(script.body().contains("const currentFile = JSON.stringify({content: configurationContent.value, fileName: configurationFile.value,")); - assertTrue(script.body().contains("submittedFile === currentFile"), - "File apply completion must compare content, target scope, file, and node sessions."); + assertTrue(script.body().contains("const expectedApplyGeneration = inputGeneration + 2;")); + assertTrue(script.body().contains("const submittedContextStillCurrent = inputGeneration === expectedApplyGeneration")); + assertTrue(script.body().contains("approval.sessions.get(nodeId) === nodeIndex.get(nodeId)?.sessionId"), + "File apply completion must compare input generation, target scope, file, and node sessions."); assertTrue(script.body().contains("The apply completed, but newer unsaved file edits remain. Preview again before applying them."), "A file apply must not label edits made during polling as already saved."); assertTrue(script.body().contains("const previewGeneration = inputGeneration;\n try {\n const nodeIds = backendQuickTargets();")); @@ -334,7 +346,7 @@ class ControlHttpServerTest { assertTrue(script.body().contains("handleEditorKeydown")); assertTrue(script.body().contains("receivedLastVotes.length > MAX_PLAYER_LAST_VOTES")); assertTrue(script.body().contains(".slice(0, MAX_PLAYER_LAST_VOTES)")); - assertTrue(script.body().contains("if (!automatic) text(fileOperationStatus, error.message);")); + assertFalse(script.body().contains("if (!automatic) text(fileOperationStatus, error.message);")); assertTrue(script.body().contains("const cell = document.createElement('td');")); assertTrue(script.body().contains("const submittedProposal = JSON.stringify({proposal: proposal(), nodeIds: approval.nodeIds});")); assertTrue(script.body().contains("const currentProposal = JSON.stringify({proposal: proposal(), nodeIds: targets('config.proxy-routing.v1')});")); @@ -525,7 +537,7 @@ class ControlHttpServerTest { "Setup diagnostics must invalidate any cached dashboard evidence."); assertTrue(script.body().contains("function invalidVoteLoggingState(value)")); assertTrue(script.body().contains("Object.hasOwn(value, 'voteLogAvailable') ? value.voteLogAvailable : value.voteLoggingAvailable")); - assertTrue(script.body().contains("const proxyMethods = new Set(['PLUGINMESSAGING', 'REDIS', 'MQTT', 'MYSQL', 'SOCKETS']);")); + assertTrue(script.body().contains("const proxyMethods = new Set(['PLUGINMESSAGING', 'REDIS', 'MQTT', 'MYSQL', 'SOCKETS', 'HTTP']);")); assertTrue(script.body().contains("result.proxyMode === true && !proxyMethods.has(result.proxyMethod.toUpperCase())")); assertTrue(script.body().contains("const requiredStrings = new Set(['pluginVersion', 'platform', 'serverSoftware', 'serverVersion', 'dataStorage']);")); assertTrue(script.body().contains("field === 'proxyMethod'")); From 07bbedc52d7012a5b535b5b606991bfd343037c7 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sun, 13 Sep 2026 11:55:25 -0600 Subject: [PATCH 02/19] Harden HTTP quick setup capability routing --- docs/control-management.md | 3 +++ .../domain/ConfigurationOperations.java | 11 ++++++++++ .../protocol/ManagedConfiguration.java | 7 +++++++ src/main/resources/web/app.js | 20 +++++++++++++++---- .../domain/ConfigurationOperationsTest.java | 9 +++++++++ .../control/http/ControlHttpServerTest.java | 3 +++ 6 files changed, 49 insertions(+), 4 deletions(-) diff --git a/docs/control-management.md b/docs/control-management.md index 93e9abca..dbad3340 100644 --- a/docs/control-management.md +++ b/docs/control-management.md @@ -45,9 +45,12 @@ Control accepts only the intersection with its own allow-list. | `config.vote-sites-sync.v1` | Reward-safe VoteSites merge from one backend to selected targets | | `config.transport-test.v1` | Typed, bounded proxy-to-backend communication check | | `config.proxy-method.v1` | Coordinated preview/apply and acknowledged runtime replacement for a supported network proxy method | +| `config.proxy-method.v2` | HTTP proxy-method selection and HTTP backend quick setup; only paired connectors implementing the HTTP contract advertise it | | `data.inspect.v1` | Typed read-only data, health, simulation, and diagnostics requests | Do not infer support from plugin version strings. Check `acceptedCapabilities` for the exact capability. +Deploy the paired VotingPlugin connector change before enabling Control features that require a new capability. During a +mixed-version rollout, older nodes remain connected but are excluded from v2 HTTP previews and applies. ## WebUI feature map diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java index 0b04262e..85f8e2b3 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java @@ -480,6 +480,17 @@ private void validateProxyMethodTargets(ValidatedTargets targets, ManagedConfigu private static void validateConfigurationTargets(ValidatedTargets targets, ManagedConfiguration configuration) { + if (ManagedConfiguration.QUICK_SETUP.equals(configuration.domain()) + && "proxy-backend".equals(configuration.preset())) { + List invalid = targets.nodeIds().stream() + .filter(nodeId -> !"BUKKIT".equalsIgnoreCase(targets.platforms().get(nodeId))) + .toList(); + if (!invalid.isEmpty()) { + throw new ValidationException("INVALID_TARGET", + "Backend proxy settings require Bukkit nodes", invalid); + } + return; + } if (!ManagedConfiguration.FILE.equals(configuration.domain())) return; boolean proxyFile = "bungeeconfig.yml".equals(configuration.fileName()); List invalid = targets.nodeIds().stream() diff --git a/src/main/java/com/bencodez/votingplugin/control/protocol/ManagedConfiguration.java b/src/main/java/com/bencodez/votingplugin/control/protocol/ManagedConfiguration.java index 210655fa..29889d18 100644 --- a/src/main/java/com/bencodez/votingplugin/control/protocol/ManagedConfiguration.java +++ b/src/main/java/com/bencodez/votingplugin/control/protocol/ManagedConfiguration.java @@ -116,6 +116,11 @@ public void validateProposal() { .contains(options.get("method")))) { throw new IllegalArgumentException("proxy method requires one supported method"); } + if (QUICK_SETUP.equals(domain) && "proxy-backend".equals(preset) && options.containsKey("method") + && !List.of("PLUGINMESSAGING", "REDIS", "MQTT", "SOCKETS", "MYSQL", "HTTP") + .contains(options.get("method"))) { + throw new IllegalArgumentException("proxy backend requires a canonical supported method"); + } if (QUICK_SETUP.equals(domain) && REWARD_BUILDER.equals(preset) && (options.size() != 1 || !options.containsKey("proposal"))) { throw new IllegalArgumentException("reward builder requires one typed proposal"); @@ -130,6 +135,8 @@ public String capability() { ? "config.vote-sites-sync.v1" : COMMUNICATION_TEST.equals(preset) ? "config.transport-test.v1" : PROXY_METHOD.equals(preset) ? "HTTP".equals(options.get("method")) ? "config.proxy-method.v2" : "config.proxy-method.v1" + : "proxy-backend".equals(preset) && "HTTP".equals(options.get("method")) + ? "config.proxy-method.v2" : "config.quick-setup.v1"; default -> throw new IllegalStateException("unsupported configuration domain"); }; diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index a06a0fec..274432ed 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -2837,9 +2837,10 @@ function updateConfigurationButtons(busy = configurationOperationsInFlight > 0 | fileTargetsForSelection().length > 0 && !busy; const fileDraftReady = fileReady && fileDraftMatchesCurrentContext(); const syncSelected = quickPreset.value === 'sync-vote-sites'; + const quickCapability = quickSetupCapability(); const quickReady = authenticated && !busy && (syncSelected ? Boolean(voteSitesSourceId && selectedVoteSitesTargets().length > 0) - : primaryCapabilities.includes('config.quick-setup.v1') && targets('config.quick-setup.v1').length > 0); + : primaryCapabilities.includes(quickCapability) && quickSetupTargets().length > 0); readConfiguration.disabled = !routingReadReady; previewConfiguration.disabled = !routingDraftReady; applyConfiguration.disabled = !routingDraftReady || !approvedPreview; @@ -2867,6 +2868,16 @@ function backendQuickTargets() { return targets('config.quick-setup.v1').filter(nodeId => nodeIndex.has(nodeId) && isBackend(nodeIndex.get(nodeId))); } +function quickSetupCapability() { + return quickPreset.value === 'proxy-backend' && quickMethod.value === 'HTTP' + ? 'config.proxy-method.v2' : 'config.quick-setup.v1'; +} + +function quickSetupTargets() { + return targets(quickSetupCapability()) + .filter(nodeId => nodeIndex.has(nodeId) && isBackend(nodeIndex.get(nodeId))); +} + function clearApprovals() { approvedPreview = null; approvedFilePreview = null; @@ -4036,14 +4047,15 @@ previewQuickSetup.addEventListener('click', async () => { } return; } + const nodeIds = quickSetupTargets(); const operation = await startConfigurationOperation('/api/v1/configuration/preview', { - nodeIds: targets('config.quick-setup.v1'), + nodeIds, configuration: {domain: 'quick-setup', preset: quickPreset.value, options: quickOptions()} }, quickOperationStatus); text(quickOperationStatus, operationSummary(operation)); if (operation.state === 'SUCCEEDED' && operation.approvalToken && previewGeneration === inputGeneration) { approvedQuickPreview = {operationId: operation.operationId, approvalToken: operation.approvalToken, - nodeIds: targets('config.quick-setup.v1')}; + nodeIds}; updateConfigurationButtons(); } else if (previewGeneration !== inputGeneration) { text(quickOperationStatus, 'The targets or setup changed while previewing. Preview again before apply.'); @@ -4067,7 +4079,7 @@ applyQuickSetup.addEventListener('click', async () => { const operation = await startConfigurationOperation('/api/v1/configuration/apply', { previewOperationId: approval.operationId, approvalToken: approval.approvalToken }, quickOperationStatus); - const currentNodeIds = sync ? selectedVoteSitesTargets() : targets('config.quick-setup.v1'); + const currentNodeIds = sync ? selectedVoteSitesTargets() : quickSetupTargets(); const currentQuickSetup = JSON.stringify({preset: quickPreset.value, options: quickOptions(), nodeIds: currentNodeIds, sourceId: sync ? voteSitesSourceId : ''}); text(quickOperationStatus, operation.state === 'SUCCEEDED' && submittedQuickSetup !== currentQuickSetup diff --git a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java index 8d63481c..a94dd87a 100644 --- a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java @@ -880,6 +880,15 @@ class ConfigurationOperationsTest { null, null, ManagedConfiguration.PROXY_METHOD, Map.of("method", "HTTP")); http.validateProposal(); assertEquals(ConfigurationOperations.PROXY_METHOD_HTTP_CAPABILITY, http.capability()); + + ManagedConfiguration backendHttp = new ManagedConfiguration(ManagedConfiguration.QUICK_SETUP, null, + List.of(), null, null, "proxy-backend", Map.of("server", "lobby", "method", "HTTP")); + assertEquals(ConfigurationOperations.PROXY_METHOD_HTTP_CAPABILITY, backendHttp.capability()); + assertThrows(ValidationException.class, + () -> operations.createPreview(List.of("proxy-a"), backendHttp)); + ManagedConfiguration lowercaseHttp = new ManagedConfiguration(ManagedConfiguration.QUICK_SETUP, null, + List.of(), null, null, "proxy-backend", Map.of("server", "lobby", "method", "http")); + assertThrows(IllegalArgumentException.class, lowercaseHttp::validateProposal); } @Test void proxyMethodApplyKeepsEvictedBackendsAsFailedDependencies() throws Exception { diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java index 9b58334d..b7faee0a 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -130,6 +130,9 @@ class ControlHttpServerTest { assertTrue(script.body().contains("enabled: String(quickPartyEnabled.checked)")); assertTrue(script.body().contains("quickPartyEnabled.checked = options.enabled === 'true'")); assertTrue(script.body().contains("config.proxy-method.v2")); + assertTrue(script.body().contains("function quickSetupTargets()")); + assertTrue(script.body().contains("nodeIds = quickSetupTargets()")); + assertTrue(script.body().contains("currentNodeIds = sync ? selectedVoteSitesTargets() : quickSetupTargets()")); assertTrue(web.body().contains("Add a simple vote reward")); assertTrue(web.body().contains("First-run setup")); assertTrue(web.body().contains("Node enrollment")); From 121580c7529707b008b0e538fda836bd0e9feccf Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sun, 13 Sep 2026 12:10:24 -0600 Subject: [PATCH 03/19] Keep guided settings retry context-safe --- src/main/resources/web/app.js | 6 +++++- .../votingplugin/control/http/ControlHttpServerTest.java | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index 274432ed..48bec099 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -3982,7 +3982,11 @@ async function loadQuickSetupValues(automatic = false) { if (generation !== inputGeneration || preset !== quickPreset.value || nodeId !== selectedServerId || sessionId !== nodeIndex.get(nodeId)?.sessionId || selector !== JSON.stringify(quickReadOptions())) { - text(quickOperationStatus, 'The server or setup changed while reading. Load the current values again.'); + if (!quickSetupValuesLoaded()) { + text(quickOperationStatus, 'The server or setup changed while reading. Load the current values again.'); + readQuickSetup.hidden = false; + updateConfigurationButtons(); + } return; } const detected = preset === 'vote-site' && pendingDetectedVoteSite?.nodeId === nodeId diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java index b7faee0a..f0705ccb 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -127,6 +127,7 @@ class ControlHttpServerTest { assertTrue(script.body().contains("configurationContent.setAttribute('aria-busy', 'true')")); assertTrue(script.body().contains("readFileConfiguration.hidden = false;")); assertTrue(script.body().contains("readQuickSetup.hidden = false;")); + assertTrue(script.body().contains("if (!quickSetupValuesLoaded()) {\n text(quickOperationStatus, 'The server or setup changed while reading.")); assertTrue(script.body().contains("enabled: String(quickPartyEnabled.checked)")); assertTrue(script.body().contains("quickPartyEnabled.checked = options.enabled === 'true'")); assertTrue(script.body().contains("config.proxy-method.v2")); From 5533b0010fc3896edfd063275c9de247bc71b8d7 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sun, 13 Sep 2026 12:23:26 -0600 Subject: [PATCH 04/19] Make automatic quick reads capability-safe --- .../control/domain/ConfigurationOperations.java | 12 +++++++++++- src/main/resources/web/app.js | 17 +++++++++++++++-- .../domain/ConfigurationOperationsTest.java | 7 +++++++ .../control/http/ControlHttpServerTest.java | 4 ++++ 4 files changed, 37 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java index 85f8e2b3..e9372efb 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java @@ -261,13 +261,23 @@ private ConfigurationTask claimCurrentSession(String nodeId, NodeStatus node) { else operation.claimSessions.put(nodeId, previousClaimSession); throw e; } - return new ConfigurationTask(operation.id, operation.type, operation.configuration, + return new ConfigurationTask(operation.id, operation.type, configurationForTask(operation), operation.expectedRevisions.get(nodeId), attemptId); } } return null; } + private static ManagedConfiguration configurationForTask(StoredOperation operation) { + ManagedConfiguration configuration = operation.configuration; + if ("READ".equals(operation.type) && ManagedConfiguration.QUICK_SETUP.equals(configuration.domain()) + && "proxy-backend".equals(configuration.preset()) && configuration.options().containsKey("method")) { + return new ManagedConfiguration(ManagedConfiguration.QUICK_SETUP, null, List.of(), null, null, + configuration.preset(), Map.of()); + } + return configuration; + } + private boolean deferProxyMethodApply(StoredOperation operation, NodeStatus node) { if (!"APPLY".equals(operation.type) || !ManagedConfiguration.QUICK_SETUP.equals(operation.configuration.domain()) diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index 48bec099..64fb29fd 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -1619,7 +1619,11 @@ async function autoLoadTab(tab) { } finally { finishAutoLoad(tab); } return; } - if (tab === 'quick-setup' && !approvedQuickPreview && !configurationOperationsInFlight + if (tab === 'quick-setup' && configurationOperationsInFlight) { + autoLoadPending.add(tab); + return; + } + if (tab === 'quick-setup' && !approvedQuickPreview && (autoSitesState.textContent === 'Not loaded' || voteLoggingState.textContent === 'Not loaded' || quickPresetReadable() && !loadedQuickSetup)) { autoLoadInFlight.add(tab); @@ -3279,6 +3283,11 @@ async function startConfigurationOperation(path, body, statusElement = operation configurationOperationsInFlight--; updateConfigurationButtons(); updateExtendedButtons(); + if (configurationOperationsInFlight === 0 && autoLoadPending.has('quick-setup') + && !autoLoadInFlight.has('quick-setup')) { + autoLoadPending.delete('quick-setup'); + void autoLoadTab('quick-setup'); + } } } @@ -3924,6 +3933,10 @@ function quickReadOptions() { return quickPreset.value === 'vote-site' ? {name: quickName.value.trim()} : {}; } +function quickReadConfigurationOptions() { + return quickPreset.value === 'proxy-backend' ? {method: quickMethod.value} : quickReadOptions(); +} + function populateQuickState(options) { if (quickPreset.value === 'proxy-backend') { quickName.value = options.server || ''; @@ -3974,7 +3987,7 @@ async function loadQuickSetupValues(automatic = false) { try { const operation = await startConfigurationOperation('/api/v1/configuration/read', { nodeIds: [selectedServerId], - configuration: {domain: 'quick-setup', preset, options: quickReadOptions()} + configuration: {domain: 'quick-setup', preset, options: quickReadConfigurationOptions()} }, quickOperationStatus); const result = Object.values(operation.results).find(item => item.success && item.configuration?.preset === preset && item.configuration?.options); diff --git a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java index a94dd87a..e2accf30 100644 --- a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java @@ -886,6 +886,13 @@ class ConfigurationOperationsTest { assertEquals(ConfigurationOperations.PROXY_METHOD_HTTP_CAPABILITY, backendHttp.capability()); assertThrows(ValidationException.class, () -> operations.createPreview(List.of("proxy-a"), backendHttp)); + UUID httpBackendSession = UUID.randomUUID(); + registry.register(new NodeRegistration("http-backend", httpBackendSession, "HTTP Backend", "BUKKIT", + "test", 1, Set.of(ConfigurationOperations.PROXY_METHOD_HTTP_CAPABILITY), Set.of())); + operations.createRead(List.of("http-backend"), backendHttp); + ConfigurationTask httpRead = operations.claim("http-backend", httpBackendSession); + assertEquals("proxy-backend", httpRead.configuration().preset()); + assertEquals(Map.of(), httpRead.configuration().options()); ManagedConfiguration lowercaseHttp = new ManagedConfiguration(ManagedConfiguration.QUICK_SETUP, null, List.of(), null, null, "proxy-backend", Map.of("server", "lobby", "method", "http")); assertThrows(IllegalArgumentException.class, lowercaseHttp::validateProposal); diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java index f0705ccb..f55d4d80 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -134,6 +134,10 @@ class ControlHttpServerTest { assertTrue(script.body().contains("function quickSetupTargets()")); assertTrue(script.body().contains("nodeIds = quickSetupTargets()")); assertTrue(script.body().contains("currentNodeIds = sync ? selectedVoteSitesTargets() : quickSetupTargets()")); + assertTrue(script.body().contains("autoLoadPending.add(tab);")); + assertTrue(script.body().contains("configurationOperationsInFlight === 0 && autoLoadPending.has('quick-setup')")); + assertTrue(script.body().contains("function quickReadConfigurationOptions()")); + assertTrue(script.body().contains("options: quickReadConfigurationOptions()")); assertTrue(web.body().contains("Add a simple vote reward")); assertTrue(web.body().contains("First-run setup")); assertTrue(web.body().contains("Node enrollment")); From 3301cc60d92e091af8d7a5a1ea5bc7920fd88898 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sun, 13 Sep 2026 13:03:20 -0600 Subject: [PATCH 05/19] Complete automatic settings refresh paths --- src/main/resources/web/app.js | 63 ++++++++++++++----- .../control/http/ControlHttpServerTest.java | 17 ++++- 2 files changed, 63 insertions(+), 17 deletions(-) diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index 64fb29fd..939f93c4 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -1625,12 +1625,12 @@ async function autoLoadTab(tab) { } if (tab === 'quick-setup' && !approvedQuickPreview && (autoSitesState.textContent === 'Not loaded' || voteLoggingState.textContent === 'Not loaded' - || quickPresetReadable() && !loadedQuickSetup)) { + || quickPresetReadable() && !quickSetupValuesLoaded())) { autoLoadInFlight.add(tab); try { if (autoSitesState.textContent === 'Not loaded') await loadDedicatedSetup('auto-create-vote-sites', true); if (voteLoggingState.textContent === 'Not loaded') await loadDedicatedSetup('vote-logging', true); - if (quickPresetReadable() && !loadedQuickSetup) await loadQuickSetupValues(true); + if (quickPresetReadable() && !quickSetupValuesLoaded()) await loadQuickSetupValues(true); } finally { finishAutoLoad(tab); } return; } @@ -2545,13 +2545,24 @@ function renderTransportTest() { function proxyMethodCandidates() { return allNodeItems.filter(node => isProxy(node) && node.online && - node.acceptedCapabilities.includes('config.proxy-method.v1')); + (node.acceptedCapabilities.includes('config.proxy-method.v1') + || node.acceptedCapabilities.includes('config.proxy-method.v2'))); } function proxyMethodCapabilityFor(method) { return method === 'HTTP' ? 'config.proxy-method.v2' : 'config.proxy-method.v1'; } +function proxyMethodReadCapability() { + const capabilities = nodeCapabilities.get(proxyMethodProxyId) || []; + return capabilities.includes('config.proxy-method.v1') + ? 'config.proxy-method.v1' : 'config.proxy-method.v2'; +} + +function proxyMethodReadNetwork() { + return proxyMethodNetwork(proxyMethodReadCapability()); +} + function proxyMethodNetworkFor(items, truncatedNodeIds, proxyId, capability = 'config.proxy-method.v1') { const index = new Map(items.map(node => [node.nodeId, node])); const proxy = index.get(proxyId); @@ -2588,7 +2599,7 @@ function renderProxyMethod() { return option; })); proxyMethodProxy.value = proxyMethodProxyId; - const network = proxyMethodNetwork(); + const network = proxyMethodReadNetwork(); if (proxyMethodCurrentFor !== proxyMethodProxyId || proxyMethodCurrentSessionId !== (network.proxy?.sessionId || '')) { proxyMethodCurrentFor = ''; @@ -2855,7 +2866,7 @@ function updateConfigurationButtons(busy = configurationOperationsInFlight > 0 | previewQuickSetup.disabled = !quickReady || (quickPresetNeedsRead() && !quickSetupValuesLoaded()); applyQuickSetup.disabled = !quickReady || !approvedQuickPreview; runTransportTest.disabled = !authenticated || !transportTestProxyId || !transportTestBackendId || busy; - const methodNetwork = proxyMethodNetwork(); + const methodNetwork = proxyMethodReadNetwork(); proxyMethodButtons.forEach(button => { const network = proxyMethodNetwork(proxyMethodCapabilityFor(button.dataset.proxyMethod)); button.disabled = !authenticated || !network.proxyReady || !network.topologyComplete || network.reported.length === 0 @@ -3974,7 +3985,7 @@ function populateQuickState(options) { } async function loadQuickSetupValues(automatic = false) { - if (!quickPresetReadable()) return; + if (!quickPresetReadable()) return false; approvedQuickPreview = null; loadedQuickSetup = null; const preset = quickPreset.value; @@ -4000,7 +4011,7 @@ async function loadQuickSetupValues(automatic = false) { readQuickSetup.hidden = false; updateConfigurationButtons(); } - return; + return false; } const detected = preset === 'vote-site' && pendingDetectedVoteSite?.nodeId === nodeId && pendingDetectedVoteSite.key === quickName.value.trim() ? pendingDetectedVoteSite : null; @@ -4021,6 +4032,7 @@ async function loadQuickSetupValues(automatic = false) { text(quickOperationStatus, `Current values loaded from ${Object.keys(operation.results).find(id => operation.results[id] === result)}.${suffix}`); readQuickSetup.hidden = true; updateConfigurationButtons(); + return true; } catch (error) { if (authenticated && generation === inputGeneration && preset === quickPreset.value && nodeId === selectedServerId && sessionId === nodeIndex.get(nodeId)?.sessionId @@ -4030,6 +4042,7 @@ async function loadQuickSetupValues(automatic = false) { readQuickSetup.hidden = false; updateConfigurationButtons(); } + return false; } } @@ -4158,19 +4171,21 @@ runTransportTest.addEventListener('click', async () => { async function loadProxyMethod(automatic = false) { const proxyId = proxyMethodProxyId; - const sessionId = proxyMethodNetwork().proxy?.sessionId; + const readCapability = proxyMethodReadCapability(); + const sessionId = proxyMethodNetwork(readCapability).proxy?.sessionId; const requestAuthenticationGeneration = authenticationGeneration; if (!proxyId) return; try { const operation = await startConfigurationOperation('/api/v1/configuration/read', { nodeIds: [proxyId], - configuration: {domain: 'quick-setup', preset: 'proxy-method', options: {method: 'PLUGINMESSAGING'}} + configuration: {domain: 'quick-setup', preset: 'proxy-method', + options: {method: readCapability === 'config.proxy-method.v2' ? 'HTTP' : 'PLUGINMESSAGING'}} }, proxyMethodStatus); const result = operation.results[proxyId]; const method = result?.success ? result.configuration?.options?.method : ''; if (!method) throw new Error('The proxy did not return its active communication method.'); if (requestAuthenticationGeneration !== authenticationGeneration || proxyId !== proxyMethodProxyId - || sessionId !== proxyMethodNetwork().proxy?.sessionId || result?.sessionId !== sessionId) return; + || sessionId !== proxyMethodNetwork(readCapability).proxy?.sessionId || result?.sessionId !== sessionId) return; proxyMethodCurrentFor = proxyId; proxyMethodCurrentSessionId = sessionId; proxyMethodCurrentValue = method; @@ -4189,7 +4204,7 @@ proxyMethodProxy.addEventListener('change', () => { proxyMethodCurrentSessionId = ''; proxyMethodCurrentValue = ''; renderProxyMethod(); - const network = proxyMethodNetwork(); + const network = proxyMethodReadNetwork(); text(proxyMethodStatus, network.unavailable.length > 0 ? `Cannot switch yet. Enroll, update, and connect: ${network.unavailable.map(backend => backend.displayName).join(', ')}.` : 'Choose a method to preflight every node before applying.'); @@ -4915,10 +4930,8 @@ profilePicker.addEventListener('change', () => { loadProfile.disabled = !profilePicker.value; deleteProfile.disabled = !profilePicker.value; }); -loadProfile.addEventListener('click', () => { - const profile = readProfiles()[profilePicker.value]; - if (!profile || profile.version !== 1) { text(profileStatus, 'That profile is unavailable or unsupported.'); return; } - pendingDetectedVoteSite = null; + +function applyProfileValues(profile) { const assign = (field, value, max = 500) => { field.value = String(value ?? '').slice(0, max); }; if ([...quickPreset.options].some(option => option.value === profile.preset)) quickPreset.value = profile.preset; assign(quickName, profile.name, 64); assign(quickMethod, profile.method, 32); @@ -4945,10 +4958,28 @@ loadProfile.addEventListener('click', () => { rewardSiteLabel.hidden = rewardScope.value !== 'site'; copyRewardToSetup.disabled = boundedLines(rewardCommands.value).length === 0; } +} + +loadProfile.addEventListener('click', async () => { + const profile = readProfiles()[profilePicker.value]; + if (!profile || profile.version !== 1) { text(profileStatus, 'That profile is unavailable or unsupported.'); return; } + pendingDetectedVoteSite = null; + if ([...quickPreset.options].some(option => option.value === profile.preset)) quickPreset.value = profile.preset; + quickName.value = String(profile.name ?? '').slice(0, 64); + quickMethod.value = String(profile.method ?? '').slice(0, 32); loadedQuickSetup = null; updateQuickFields(); clearApprovals(); - text(profileStatus, `Loaded “${profilePicker.value}”. Load live values first if this preset edits existing configuration.`); + text(profileStatus, `Loading live values before applying “${profilePicker.value}”…`); + if (quickPresetReadable() && !await loadQuickSetupValues(true)) { + text(profileStatus, `Could not load live values for “${profilePicker.value}”. Retry before using this profile.`); + return; + } + applyProfileValues(profile); + inputGeneration++; + updateQuickFields(); + clearApprovals(); + text(profileStatus, `Loaded “${profilePicker.value}” over the confirmed live values. Preview before applying.`); }); deleteProfile.addEventListener('click', () => { const name = profilePicker.value; diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java index f55d4d80..d72d499e 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -116,7 +116,7 @@ class ControlHttpServerTest { assertTrue(script.body().contains("MAX_OPERATION_TARGETS = 100")); assertTrue(script.body().contains("proxyMethodNetworkSignature(refreshedNetwork)")); assertTrue(script.body().contains("proxyMethodCurrentSessionId !== (network.proxy?.sessionId || '')")); - assertTrue(script.body().contains("sessionId !== proxyMethodNetwork().proxy?.sessionId")); + assertTrue(script.body().contains("sessionId !== proxyMethodNetwork(readCapability).proxy?.sessionId")); assertTrue(script.body().contains("refreshedNetwork.proxy?.sessionId !== network.proxy.sessionId")); assertTrue(script.body().contains("if (approvedQuickPreview?.workflow === 'sync-vote-sites') approvedQuickPreview = null;")); assertTrue(script.body().contains("if (quickPreset.value !== 'sync-vote-sites') return;")); @@ -216,6 +216,10 @@ class ControlHttpServerTest { assertTrue(script.body().contains( "configurationContent.addEventListener('input', () => {\n if (!configurationDirty) {\n configurationDraftNodeId = selectedServerId;")); assertTrue(script.body().contains("quickPresetNeedsRead() && !quickSetupValuesLoaded()")); + assertFalse(script.body().contains("quickPresetReadable() && !loadedQuickSetup"), + "Quick-setup autoload must re-read when the loaded vote-site selector changes."); + assertTrue(script.body().contains("quickPresetReadable() && !quickSetupValuesLoaded()"), + "Quick-setup autoload must validate the loaded selector before deciding it is current."); assertTrue(script.body().contains("loadedQuickSetup.sessionId === nodeIndex.get(selectedServerId)?.sessionId")); assertTrue(script.body().contains("previousNodeIndex.get(selectedServerId)?.sessionId !== nodeIndex.get(selectedServerId)?.sessionId")); assertTrue(script.body().contains("sessionId !== nodeIndex.get(nodeId)?.sessionId")); @@ -681,6 +685,17 @@ class ControlHttpServerTest { assertTrue(script.body().contains("if (autoLoadInFlight.has(tab)) {\n autoLoadPending.add(tab);")); assertTrue(script.body().contains("if (autoLoadPending.delete(tab)) void autoLoadTab(tab);"), "A preset change during an older read must queue a fresh autoload."); + assertTrue(script.body().contains("quickPresetReadable() && !await loadQuickSetupValues(true)"), + "Loading a saved profile must read live values before enabling the template."); + assertTrue(script.body().contains("applyProfileValues(profile);"), + "The saved template must be restored after the live read rather than overwritten by it."); + assertTrue(script.body().contains("node.acceptedCapabilities.includes('config.proxy-method.v1')\n || node.acceptedCapabilities.includes('config.proxy-method.v2')"), + "An HTTP v2-only proxy must be selectable while each method action still checks its exact capability."); + assertTrue(script.body().contains("const methodNetwork = proxyMethodNetwork(proxyMethodCapabilityFor(button.dataset.proxyMethod));"), + "Method actions must retain their exact per-method capability check."); + assertTrue(script.body().contains("const readCapability = proxyMethodReadCapability();")); + assertTrue(script.body().contains("readCapability === 'config.proxy-method.v2' ? 'HTTP' : 'PLUGINMESSAGING'"), + "A v2-only proxy must read its current method through the capability it advertises."); assertTrue(script.body().contains("autoLoadPending.clear();")); int globalShortcut = script.body().indexOf("function openGlobalShortcut(destination)"); int selectConfigView = script.body().indexOf("setConfigView(destination.configView);", globalShortcut); From 0f10614e7de0d9a99960de925367df6ae4506e84 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sun, 13 Sep 2026 13:13:59 -0600 Subject: [PATCH 06/19] Bind backend reads to the active method --- src/main/resources/web/app.js | 11 ++++++----- .../control/http/ControlHttpServerTest.java | 5 ++++- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index 939f93c4..c0719abc 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -2958,7 +2958,7 @@ function quickSetupValuesLoaded() { return loadedQuickSetup?.nodeId === selectedServerId && loadedQuickSetup.sessionId === nodeIndex.get(selectedServerId)?.sessionId && loadedQuickSetup.preset === quickPreset.value - && loadedQuickSetup.selector === JSON.stringify(quickReadOptions()); + && loadedQuickSetup.selector === JSON.stringify(quickReadConfigurationOptions()); } function updatePluginSuggestions() { @@ -3991,7 +3991,7 @@ async function loadQuickSetupValues(automatic = false) { const preset = quickPreset.value; const nodeId = selectedServerId; const sessionId = nodeIndex.get(nodeId)?.sessionId; - const selector = JSON.stringify(quickReadOptions()); + const selector = JSON.stringify(quickReadConfigurationOptions()); const generation = inputGeneration; readQuickSetup.hidden = true; text(quickOperationStatus, `Loading current ${preset} settings from ${nodeId}…`); @@ -4005,7 +4005,7 @@ async function loadQuickSetupValues(automatic = false) { if (!result) throw new Error('The selected backend did not return guided settings. Update VotingPlugin on that node.'); if (generation !== inputGeneration || preset !== quickPreset.value || nodeId !== selectedServerId || sessionId !== nodeIndex.get(nodeId)?.sessionId - || selector !== JSON.stringify(quickReadOptions())) { + || selector !== JSON.stringify(quickReadConfigurationOptions())) { if (!quickSetupValuesLoaded()) { text(quickOperationStatus, 'The server or setup changed while reading. Load the current values again.'); readQuickSetup.hidden = false; @@ -4021,7 +4021,8 @@ async function loadQuickSetupValues(automatic = false) { quickService.value = detected.service; } if (detected) pendingDetectedVoteSite = null; - loadedQuickSetup = {nodeId, sessionId, preset, selector}; + loadedQuickSetup = {nodeId, sessionId, preset, + selector: JSON.stringify(quickReadConfigurationOptions())}; inputGeneration++; const suffix = preset === 'vote-site' && result.configuration.options.exists === 'false' ? ` This site key does not exist yet; the form is ready to create it.${detected ? ' The detected service was retained.' : ''}` @@ -4036,7 +4037,7 @@ async function loadQuickSetupValues(automatic = false) { } catch (error) { if (authenticated && generation === inputGeneration && preset === quickPreset.value && nodeId === selectedServerId && sessionId === nodeIndex.get(nodeId)?.sessionId - && selector === JSON.stringify(quickReadOptions())) { + && selector === JSON.stringify(quickReadConfigurationOptions())) { loadedQuickSetup = null; text(quickOperationStatus, `Could not load current ${preset} settings: ${error.message}`); readQuickSetup.hidden = false; diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java index d72d499e..1cea6a84 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -138,6 +138,9 @@ class ControlHttpServerTest { assertTrue(script.body().contains("configurationOperationsInFlight === 0 && autoLoadPending.has('quick-setup')")); assertTrue(script.body().contains("function quickReadConfigurationOptions()")); assertTrue(script.body().contains("options: quickReadConfigurationOptions()")); + assertTrue(script.body().contains("loadedQuickSetup.selector === JSON.stringify(quickReadConfigurationOptions())")); + assertTrue(script.body().contains("selector: JSON.stringify(quickReadConfigurationOptions())"), + "The retained selector must reflect the method returned by the live backend read."); assertTrue(web.body().contains("Add a simple vote reward")); assertTrue(web.body().contains("First-run setup")); assertTrue(web.body().contains("Node enrollment")); @@ -313,7 +316,7 @@ class ControlHttpServerTest { assertTrue(script.body().contains("Discard unsaved routing changes and load current values?")); assertTrue(script.body().contains("Discard the unsaved ${configurationFile.value} draft and read/reload the current file for this server?")); assertTrue(script.body().contains("window.addEventListener('beforeunload'")); - assertTrue(script.body().contains("loadedQuickSetup = {nodeId, sessionId, preset, selector}")); + assertTrue(script.body().contains("loadedQuickSetup = {nodeId, sessionId, preset,")); assertTrue(script.body().contains("configurationOperationsInFlight")); assertTrue(script.body().contains("if (selectedCapabilitiesChanged) {\n approvedPreview = null;")); assertTrue(script.body().contains("approvedPreview.nodeIds.every")); From 032172451fe9be2a5e4502c87aecd68298bc89af Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sun, 13 Sep 2026 13:27:07 -0600 Subject: [PATCH 07/19] Fix guided settings stale-read handling --- src/main/resources/web/app.js | 41 ++++++++++++++----- .../control/http/ControlHttpServerTest.java | 16 ++++++++ 2 files changed, 47 insertions(+), 10 deletions(-) diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index c0719abc..5c3b38b5 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -1628,8 +1628,17 @@ async function autoLoadTab(tab) { || quickPresetReadable() && !quickSetupValuesLoaded())) { autoLoadInFlight.add(tab); try { + const autoLoadGeneration = inputGeneration; if (autoSitesState.textContent === 'Not loaded') await loadDedicatedSetup('auto-create-vote-sites', true); + if (inputGeneration !== autoLoadGeneration) { + autoLoadPending.add(tab); + return; + } if (voteLoggingState.textContent === 'Not loaded') await loadDedicatedSetup('vote-logging', true); + if (inputGeneration !== autoLoadGeneration) { + autoLoadPending.add(tab); + return; + } if (quickPresetReadable() && !quickSetupValuesLoaded()) await loadQuickSetupValues(true); } finally { finishAutoLoad(tab); } return; @@ -2683,13 +2692,13 @@ function updateExtendedButtons() { previewReward.disabled = !quickReady; applyReward.disabled = !quickReady || !dedicatedSetupApprovals.get('reward-builder'); loadAutoSites.disabled = !quickReady; - previewAutoSites.disabled = !quickReady; + previewAutoSites.disabled = !quickReady || autoSitesState.textContent === 'Not loaded'; applyAutoSites.disabled = !quickReady || !dedicatedSetupApprovals.get('auto-create-vote-sites'); selectAllAutoSitesTargets.disabled = !authenticated || allQuickBackends.length === 0 || configurationOperationsInFlight > 0; text(autoSitesTargetCount, `${backendTargets.length} selected ${backendTargets.length === 1 ? 'backend' : 'backends'}`); loadVoteLogging.disabled = !quickReady; - previewVoteLogging.disabled = !quickReady; + previewVoteLogging.disabled = !quickReady || voteLoggingState.textContent === 'Not loaded'; applyVoteLogging.disabled = !quickReady || !dedicatedSetupApprovals.get('vote-logging'); runDriftCheck.disabled = !driftReady; createSnapshot.disabled = !lastFileReadOperation; @@ -3468,7 +3477,7 @@ async function loadNodesOnce() { nodeCapabilities.get(node)?.includes(selectedFileCapability())); const invalidQuickApproval = approvedQuickPreview && approvedQuickPreview.workflow !== 'sync-vote-sites' && !approvedQuickPreview.nodeIds.every(node => - nodeCapabilities.get(node)?.includes('config.quick-setup.v1')); + nodeCapabilities.get(node)?.includes(quickSetupCapability())); const invalidVoteSitesApproval = approvedQuickPreview?.workflow === 'sync-vote-sites' && (!approvedQuickPreview.nodeIds.every(node => nodeCapabilities.get(node)?.includes('config.vote-sites-sync.v1')) || @@ -4308,10 +4317,11 @@ async function loadDedicatedSetup(preset, automatic = false) { elements.state.className = `pill ${options.enabled === 'true' ? 'online' : 'neutral'}`; text(elements.status, operationSummary(operation)); } catch (error) { - if (requestNodeId === selectedServerId && requestSessionId === nodeIndex.get(requestNodeId)?.sessionId - && requestGeneration === inputGeneration) { + if (authenticated && requestNodeId === selectedServerId + && requestSessionId === nodeIndex.get(requestNodeId)?.sessionId) { text(elements.status, `Could not load current ${preset} settings: ${error.message}`); elements.retry.hidden = false; + if (automatic && requestGeneration !== inputGeneration) void autoLoadTab('quick-setup'); } } updateExtendedButtons(); @@ -4944,7 +4954,8 @@ function applyProfileValues(profile) { assign(quickMessage, profile.playerMessage, 500); quickProcessRewards.checked = Boolean(profile.processRewards); quickAutoSites.checked = Boolean(profile.autoSites); quickExtraCheck.checked = Boolean(profile.extraCheck); quickCountFake.checked = Boolean(profile.countFake); quickHideSiteWarning.checked = Boolean(profile.hideWarning); - quickDisableUpdates.checked = Boolean(profile.disableUpdates); quickPartyEnabled.checked = Boolean(profile.partyEnabled); + quickDisableUpdates.checked = Boolean(profile.disableUpdates); + if (Object.hasOwn(profile, 'partyEnabled')) quickPartyEnabled.checked = Boolean(profile.partyEnabled); assign(quickPartyVotes, profile.partyVotes, 6); assign(quickPartyCommand, profile.partyCommand, 500); assign(quickPartyBroadcast, profile.partyBroadcast, 500); quickPartyAll.checked = Boolean(profile.partyAll); quickPartyOnline.checked = Boolean(profile.partyOnline); @@ -4962,8 +4973,10 @@ function applyProfileValues(profile) { } loadProfile.addEventListener('click', async () => { - const profile = readProfiles()[profilePicker.value]; + const profileName = profilePicker.value; + const profile = readProfiles()[profileName]; if (!profile || profile.version !== 1) { text(profileStatus, 'That profile is unavailable or unsupported.'); return; } + const profileSignature = JSON.stringify(profile); pendingDetectedVoteSite = null; if ([...quickPreset.options].some(option => option.value === profile.preset)) quickPreset.value = profile.preset; quickName.value = String(profile.name ?? '').slice(0, 64); @@ -4971,16 +4984,21 @@ loadProfile.addEventListener('click', async () => { loadedQuickSetup = null; updateQuickFields(); clearApprovals(); - text(profileStatus, `Loading live values before applying “${profilePicker.value}”…`); + text(profileStatus, `Loading live values before applying “${profileName}”…`); if (quickPresetReadable() && !await loadQuickSetupValues(true)) { - text(profileStatus, `Could not load live values for “${profilePicker.value}”. Retry before using this profile.`); + text(profileStatus, `Could not load live values for “${profileName}”. Retry before using this profile.`); + return; + } + const currentProfile = readProfiles()[profileName]; + if (profilePicker.value !== profileName || !currentProfile || JSON.stringify(currentProfile) !== profileSignature) { + text(profileStatus, 'The selected profile changed while loading live values. Select it again before applying it.'); return; } applyProfileValues(profile); inputGeneration++; updateQuickFields(); clearApprovals(); - text(profileStatus, `Loaded “${profilePicker.value}” over the confirmed live values. Preview before applying.`); + text(profileStatus, `Loaded “${profileName}” over the confirmed live values. Preview before applying.`); }); deleteProfile.addEventListener('click', () => { const name = profilePicker.value; @@ -5002,6 +5020,9 @@ clearOperationHistory.addEventListener('click', loadOperationHistory); quickHideSiteWarning, quickDisableUpdates, quickPartyEnabled, quickPartyVotes, quickPartyCommand, quickPartyBroadcast, quickPartyAll, quickPartyOnline, quickAutoSitesOnly, quickVoteLoggingEnabled, quickVoteLoggingDays, quickVoteLoggingMainMysql].forEach(field => field.addEventListener('input', clearApprovals)); +quickMethod.addEventListener('input', () => { + if (quickPreset.value === 'proxy-backend' && quickPresetReadable()) void autoLoadTab('quick-setup'); +}); quickName.addEventListener('input', () => { if (pendingDetectedVoteSite && pendingDetectedVoteSite.key !== quickName.value.trim()) pendingDetectedVoteSite = null; updateQuickFields(); diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java index 1cea6a84..827e861d 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -130,6 +130,8 @@ class ControlHttpServerTest { assertTrue(script.body().contains("if (!quickSetupValuesLoaded()) {\n text(quickOperationStatus, 'The server or setup changed while reading.")); assertTrue(script.body().contains("enabled: String(quickPartyEnabled.checked)")); assertTrue(script.body().contains("quickPartyEnabled.checked = options.enabled === 'true'")); + assertTrue(script.body().contains("if (Object.hasOwn(profile, 'partyEnabled')) quickPartyEnabled.checked = Boolean(profile.partyEnabled);"), + "Legacy v1 profiles must preserve the live Vote Party enabled state when they omit that field."); assertTrue(script.body().contains("config.proxy-method.v2")); assertTrue(script.body().contains("function quickSetupTargets()")); assertTrue(script.body().contains("nodeIds = quickSetupTargets()")); @@ -139,6 +141,20 @@ class ControlHttpServerTest { assertTrue(script.body().contains("function quickReadConfigurationOptions()")); assertTrue(script.body().contains("options: quickReadConfigurationOptions()")); assertTrue(script.body().contains("loadedQuickSetup.selector === JSON.stringify(quickReadConfigurationOptions())")); + assertTrue(script.body().contains("const autoLoadGeneration = inputGeneration;")); + assertTrue(script.body().contains("if (inputGeneration !== autoLoadGeneration) {\n autoLoadPending.add(tab);\n return;\n }"), + "A stale dedicated read must fence the remainder of the automatic quick-setup sequence."); + assertTrue(script.body().contains("quickMethod.addEventListener('input', () => {\n if (quickPreset.value === 'proxy-backend' && quickPresetReadable()) void autoLoadTab('quick-setup');"), + "Changing a proxy-backend method must schedule a capability-correct reread."); + assertTrue(script.body().contains("previewAutoSites.disabled = !quickReady || autoSitesState.textContent === 'Not loaded';")); + assertTrue(script.body().contains("previewVoteLogging.disabled = !quickReady || voteLoggingState.textContent === 'Not loaded';")); + assertTrue(script.body().contains("if (automatic && requestGeneration !== inputGeneration) void autoLoadTab('quick-setup');"), + "Discarded automatic dedicated reads must request a fresh read rather than leave defaults previewable."); + assertTrue(script.body().contains("nodeCapabilities.get(node)?.includes(quickSetupCapability())"), + "Quick approvals must remain valid only for their selected capability version."); + assertTrue(script.body().contains("const profileName = profilePicker.value;")); + assertTrue(script.body().contains("profilePicker.value !== profileName || !currentProfile || JSON.stringify(currentProfile) !== profileSignature"), + "Profile application must verify its selection after waiting for live values."); assertTrue(script.body().contains("selector: JSON.stringify(quickReadConfigurationOptions())"), "The retained selector must reflect the method returned by the live backend read."); assertTrue(web.body().contains("Add a simple vote reward")); From c76a5a5db2ba8154ea649ad86156be796cbb40bc Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sun, 13 Sep 2026 13:37:17 -0600 Subject: [PATCH 08/19] Invalidate dedicated setup after external applies --- src/main/resources/web/app.js | 6 +++++- .../votingplugin/control/http/ControlHttpServerTest.java | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index 5c3b38b5..0f9faa57 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -1079,7 +1079,11 @@ async function loadOperationHistoryOnce() { operationHistoryItems = retainedOperations.slice(0, MAX_OPERATION_HISTORY).map(operation => ({...operation, results: Object.fromEntries(Object.entries(operation.results || {}).map(([nodeId, result]) => [nodeId, result ? {...result, configuration: null} : result]))})); - if (observedSuccessfulApply) invalidateConfigurationReads(); + if (observedSuccessfulApply) { + invalidateConfigurationReads(); + resetDedicatedSetupValues(); + if (tabFromHash() === 'quick-setup') window.setTimeout(() => void autoLoadTab('quick-setup'), 0); + } const pendingRestarts = new Map(); const restartSessions = body.voteLoggingRestartSessions; if (restartSessions && typeof restartSessions === 'object' && !Array.isArray(restartSessions)) { diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java index 827e861d..3bbf0483 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -150,6 +150,10 @@ class ControlHttpServerTest { assertTrue(script.body().contains("previewVoteLogging.disabled = !quickReady || voteLoggingState.textContent === 'Not loaded';")); assertTrue(script.body().contains("if (automatic && requestGeneration !== inputGeneration) void autoLoadTab('quick-setup');"), "Discarded automatic dedicated reads must request a fresh read rather than leave defaults previewable."); + assertTrue(script.body().contains("if (observedSuccessfulApply) {\n invalidateConfigurationReads();\n resetDedicatedSetupValues();"), + "Observed external applies must invalidate the dedicated setup cards as well as the main editor."); + assertTrue(script.body().contains("if (tabFromHash() === 'quick-setup') window.setTimeout(() => void autoLoadTab('quick-setup'), 0);"), + "Invalidated dedicated settings must automatically reload while Quick Setup is visible."); assertTrue(script.body().contains("nodeCapabilities.get(node)?.includes(quickSetupCapability())"), "Quick approvals must remain valid only for their selected capability version."); assertTrue(script.body().contains("const profileName = profilePicker.value;")); @@ -292,7 +296,7 @@ class ControlHttpServerTest { assertTrue(script.body().contains("serverConfigurationGeneration > observedServerConfigurationGeneration")); assertTrue(script.body().contains("Math.max(observedServerConfigurationGeneration, serverConfigurationGeneration)"), "A delayed older response must not move the observed server generation backwards."); - assertTrue(script.body().contains("if (observedSuccessfulApply) invalidateConfigurationReads();"), + assertTrue(script.body().contains("if (observedSuccessfulApply) {\n invalidateConfigurationReads();"), "Activity refreshes must invalidate cached health after observing an external successful apply."); assertTrue(script.body().contains("if (applied) {\n invalidateConfigurationReads();"), "Locally completed applies must use the same cache invalidation path."); From 265c779d9673bdf492ceaf46e6d5c8e21f77e604 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sun, 13 Sep 2026 13:58:16 -0600 Subject: [PATCH 09/19] Preserve guided setup drafts on refresh --- src/main/resources/web/app.js | 81 ++++++++++++++++--- .../control/http/ControlHttpServerTest.java | 24 +++++- 2 files changed, 89 insertions(+), 16 deletions(-) diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index 0f9faa57..00a5182f 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -253,6 +253,9 @@ let approvedPreview = null; let approvedFilePreview = null; let approvedQuickPreview = null; let loadedQuickSetup = null; +let quickSetupDirty = false; +let quickSetupPreserveReadGeneration = -1; +const dedicatedSetupDirty = new Set(); let voteSitesSourceId = ''; let voteSitesTargetIds = new Set(); let voteSitesTargetsInitialized = false; @@ -1081,7 +1084,7 @@ async function loadOperationHistoryOnce() { [nodeId, result ? {...result, configuration: null} : result]))})); if (observedSuccessfulApply) { invalidateConfigurationReads(); - resetDedicatedSetupValues(); + invalidateGuidedSetupReads(); if (tabFromHash() === 'quick-setup') window.setTimeout(() => void autoLoadTab('quick-setup'), 0); } const pendingRestarts = new Map(); @@ -1244,6 +1247,7 @@ function applyAuthenticatedSession(body) { approvedFilePreview = null; approvedQuickPreview = null; loadedQuickSetup = null; + quickSetupDirty = false; selectedNodes.clear(); voteSitesSourceId = ''; voteSitesTargetIds.clear(); @@ -1407,7 +1411,8 @@ function nodeCard(node) { selector.className = 'node-select'; const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; - const controllable = ['config.proxy-routing.v1', 'config.files.v1', 'config.proxy-files.v1', 'config.quick-setup.v1'] + const controllable = ['config.proxy-routing.v1', 'config.files.v1', 'config.proxy-files.v1', + 'config.quick-setup.v1', 'config.proxy-method.v2'] .some(capability => node.acceptedCapabilities.includes(capability)); checkbox.disabled = !node.online || !controllable || node.nodeId === selectedServerId; checkbox.checked = selectedNodes.has(node.nodeId); @@ -1628,22 +1633,33 @@ async function autoLoadTab(tab) { return; } if (tab === 'quick-setup' && !approvedQuickPreview - && (autoSitesState.textContent === 'Not loaded' || voteLoggingState.textContent === 'Not loaded' - || quickPresetReadable() && !quickSetupValuesLoaded())) { + && (!dedicatedSetupDirty.has('auto-create-vote-sites') && autoSitesState.textContent === 'Not loaded' + || !dedicatedSetupDirty.has('vote-logging') && voteLoggingState.textContent === 'Not loaded' + || quickPresetReadable() && (!quickSetupDirty || quickSetupPreserveReadGeneration === inputGeneration) + && !quickSetupValuesLoaded())) { autoLoadInFlight.add(tab); try { const autoLoadGeneration = inputGeneration; - if (autoSitesState.textContent === 'Not loaded') await loadDedicatedSetup('auto-create-vote-sites', true); + if (!dedicatedSetupDirty.has('auto-create-vote-sites') && autoSitesState.textContent === 'Not loaded') { + await loadDedicatedSetup('auto-create-vote-sites', true); + } if (inputGeneration !== autoLoadGeneration) { autoLoadPending.add(tab); return; } - if (voteLoggingState.textContent === 'Not loaded') await loadDedicatedSetup('vote-logging', true); + if (!dedicatedSetupDirty.has('vote-logging') && voteLoggingState.textContent === 'Not loaded') { + await loadDedicatedSetup('vote-logging', true); + } if (inputGeneration !== autoLoadGeneration) { autoLoadPending.add(tab); return; } - if (quickPresetReadable() && !quickSetupValuesLoaded()) await loadQuickSetupValues(true); + if (quickPresetReadable() && (!quickSetupDirty || quickSetupPreserveReadGeneration === inputGeneration) + && !quickSetupValuesLoaded()) { + const preserveGeneration = quickSetupPreserveReadGeneration; + await loadQuickSetupValues(true, quickSetupDirty); + if (quickSetupPreserveReadGeneration === preserveGeneration) quickSetupPreserveReadGeneration = -1; + } } finally { finishAutoLoad(tab); } return; } @@ -2776,6 +2792,7 @@ function resetServerConfigurationForms(status, preserveDirtyDrafts = false) { } function resetDedicatedSetupValues() { + dedicatedSetupDirty.clear(); autoSitesEnabled.checked = false; voteLoggingEnabled.checked = false; voteLoggingDays.value = '30'; @@ -2788,6 +2805,23 @@ function resetDedicatedSetupValues() { loadVoteLogging.hidden = true; } +function invalidateGuidedSetupReads() { + loadedQuickSetup = null; + if (quickSetupDirty) { + readQuickSetup.hidden = false; + text(quickOperationStatus, 'Configuration changed elsewhere; your unsaved guided edits were preserved. Load current values to discard them.'); + } + [['auto-create-vote-sites', autoSitesState, autoSitesStatus, loadAutoSites], + ['vote-logging', voteLoggingState, voteLoggingStatus, loadVoteLogging]].forEach(([preset, state, status, retry]) => { + text(state, 'Not loaded'); + state.className = 'pill neutral'; + retry.hidden = !dedicatedSetupDirty.has(preset); + if (dedicatedSetupDirty.has(preset)) { + text(status, 'Configuration changed elsewhere; your unsaved edits were preserved. Load current values to discard them.'); + } + }); +} + function resetServerContextValues(reason, preserveDirtyDrafts = false) { dedicatedSetupApprovals.clear(); pendingDetectedVoteSite = null; @@ -2825,6 +2859,7 @@ function resetServerContextValues(reason, preserveDirtyDrafts = false) { text(autoSitesStatus, reason); text(voteLoggingStatus, reason); loadedQuickSetup = null; + quickSetupDirty = false; readQuickSetup.hidden = true; resetServerConfigurationForms(reason, preserveDirtyDrafts); const preset = quickPreset.value; @@ -3031,6 +3066,7 @@ function discardAuthenticationState(reason) { approvedFilePreview = null; approvedQuickPreview = null; loadedQuickSetup = null; + quickSetupDirty = false; inputGeneration++; logout.hidden = true; sidebarToggle.hidden = true; @@ -3997,7 +4033,7 @@ function populateQuickState(options) { } } -async function loadQuickSetupValues(automatic = false) { +async function loadQuickSetupValues(automatic = false, preserveDirty = false) { if (!quickPresetReadable()) return false; approvedQuickPreview = null; loadedQuickSetup = null; @@ -4028,7 +4064,12 @@ async function loadQuickSetupValues(automatic = false) { } const detected = preset === 'vote-site' && pendingDetectedVoteSite?.nodeId === nodeId && pendingDetectedVoteSite.key === quickName.value.trim() ? pendingDetectedVoteSite : null; + const selectedProxyMethod = preset === 'proxy-backend' ? quickMethod.value : null; + const editedProxyServer = preserveDirty && preset === 'proxy-backend' ? quickName.value : null; populateQuickState(result.configuration.options); + if (selectedProxyMethod != null) quickMethod.value = selectedProxyMethod; + if (editedProxyServer != null) quickName.value = editedProxyServer; + quickSetupDirty = preserveDirty; if (detected && result.configuration.options.exists === 'false') { quickSiteDisplayName.value = detected.service; quickService.value = detected.service; @@ -4318,6 +4359,7 @@ async function loadDedicatedSetup(preset, automatic = false) { voteLoggingMainMysql.checked = options.useMainMySQL !== 'false'; text(voteLoggingState, voteLoggingEnabled.checked ? 'Enabled on primary' : 'Disabled on primary'); } + dedicatedSetupDirty.delete(preset); elements.state.className = `pill ${options.enabled === 'true' ? 'online' : 'neutral'}`; text(elements.status, operationSummary(operation)); } catch (error) { @@ -4413,7 +4455,9 @@ previewVoteLogging.addEventListener('click', () => previewDedicatedSetup('vote-l applyVoteLogging.addEventListener('click', () => applyDedicatedSetup('vote-logging')); [autoSitesEnabled, voteLoggingEnabled, voteLoggingDays, voteLoggingMainMysql].forEach(field => { field.addEventListener('input', () => { - dedicatedSetupApprovals.delete(field === autoSitesEnabled ? 'auto-create-vote-sites' : 'vote-logging'); + const preset = field === autoSitesEnabled ? 'auto-create-vote-sites' : 'vote-logging'; + dedicatedSetupApprovals.delete(preset); + dedicatedSetupDirty.add(preset); inputGeneration++; updateExtendedButtons(); }); @@ -4999,6 +5043,7 @@ loadProfile.addEventListener('click', async () => { return; } applyProfileValues(profile); + quickSetupDirty = true; inputGeneration++; updateQuickFields(); clearApprovals(); @@ -5018,14 +5063,25 @@ deleteProfile.addEventListener('click', () => { clearOperationHistory.addEventListener('click', loadOperationHistory); -[quickName, quickMethod, quickSiteDisplayName, quickService, quickUrl, quickDelay, +[quickSiteDisplayName, quickService, quickUrl, quickDelay, quickSitePriority, quickSiteMaterial, quickSiteEnabled, quickSiteHidden, quickRewardScope, quickCommand, quickMessage, quickProcessRewards, quickAutoSites, quickExtraCheck, quickCountFake, quickHideSiteWarning, quickDisableUpdates, quickPartyEnabled, quickPartyVotes, quickPartyCommand, quickPartyBroadcast, quickPartyAll, quickPartyOnline, quickAutoSitesOnly, quickVoteLoggingEnabled, quickVoteLoggingDays, - quickVoteLoggingMainMysql].forEach(field => field.addEventListener('input', clearApprovals)); + quickVoteLoggingMainMysql].forEach(field => field.addEventListener('input', () => { + quickSetupDirty = true; + clearApprovals(); +})); +quickMethod.addEventListener('input', clearApprovals); +quickName.addEventListener('input', () => { + if (quickPreset.value !== 'vote-site') quickSetupDirty = true; + clearApprovals(); +}); quickMethod.addEventListener('input', () => { - if (quickPreset.value === 'proxy-backend' && quickPresetReadable()) void autoLoadTab('quick-setup'); + if (quickPreset.value === 'proxy-backend' && quickPresetReadable()) { + if (quickSetupDirty) quickSetupPreserveReadGeneration = inputGeneration; + void autoLoadTab('quick-setup'); + } }); quickName.addEventListener('input', () => { if (pendingDetectedVoteSite && pendingDetectedVoteSite.key !== quickName.value.trim()) pendingDetectedVoteSite = null; @@ -5067,6 +5123,7 @@ configurationFile.addEventListener('input', () => { }); quickPreset.addEventListener('input', () => { loadedQuickSetup = null; + quickSetupDirty = false; if (quickPreset.value !== 'vote-site') pendingDetectedVoteSite = null; updateQuickFields(); clearApprovals(); diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java index 3bbf0483..9ea5cb82 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -144,18 +144,34 @@ class ControlHttpServerTest { assertTrue(script.body().contains("const autoLoadGeneration = inputGeneration;")); assertTrue(script.body().contains("if (inputGeneration !== autoLoadGeneration) {\n autoLoadPending.add(tab);\n return;\n }"), "A stale dedicated read must fence the remainder of the automatic quick-setup sequence."); - assertTrue(script.body().contains("quickMethod.addEventListener('input', () => {\n if (quickPreset.value === 'proxy-backend' && quickPresetReadable()) void autoLoadTab('quick-setup');"), + assertTrue(script.body().contains("quickMethod.addEventListener('input', () => {\n if (quickPreset.value === 'proxy-backend' && quickPresetReadable()) {")); + assertTrue(script.body().contains("if (quickSetupDirty) quickSetupPreserveReadGeneration = inputGeneration;\n void autoLoadTab('quick-setup');"), "Changing a proxy-backend method must schedule a capability-correct reread."); + assertTrue(script.body().contains("const selectedProxyMethod = preset === 'proxy-backend' ? quickMethod.value : null;")); + assertTrue(script.body().contains("if (selectedProxyMethod != null) quickMethod.value = selectedProxyMethod;"), + "The capability read must not overwrite the proxy method the operator selected for preview."); + assertTrue(script.body().contains("const editedProxyServer = preserveDirty && preset === 'proxy-backend' ? quickName.value : null;")); + assertTrue(script.body().contains("if (editedProxyServer != null) quickName.value = editedProxyServer;"), + "A capability read must preserve an edited proxy destination."); assertTrue(script.body().contains("previewAutoSites.disabled = !quickReady || autoSitesState.textContent === 'Not loaded';")); assertTrue(script.body().contains("previewVoteLogging.disabled = !quickReady || voteLoggingState.textContent === 'Not loaded';")); assertTrue(script.body().contains("if (automatic && requestGeneration !== inputGeneration) void autoLoadTab('quick-setup');"), "Discarded automatic dedicated reads must request a fresh read rather than leave defaults previewable."); - assertTrue(script.body().contains("if (observedSuccessfulApply) {\n invalidateConfigurationReads();\n resetDedicatedSetupValues();"), + assertTrue(script.body().contains("if (observedSuccessfulApply) {\n invalidateConfigurationReads();\n invalidateGuidedSetupReads();"), "Observed external applies must invalidate the dedicated setup cards as well as the main editor."); assertTrue(script.body().contains("if (tabFromHash() === 'quick-setup') window.setTimeout(() => void autoLoadTab('quick-setup'), 0);"), "Invalidated dedicated settings must automatically reload while Quick Setup is visible."); assertTrue(script.body().contains("nodeCapabilities.get(node)?.includes(quickSetupCapability())"), "Quick approvals must remain valid only for their selected capability version."); + assertTrue(script.body().contains("'config.quick-setup.v1', 'config.proxy-method.v2'"), + "Secondary v2-only backends must remain selectable for HTTP setup."); + assertTrue(script.body().contains("quickPresetReadable() && (!quickSetupDirty || quickSetupPreserveReadGeneration === inputGeneration)")); + assertTrue(script.body().contains("!dedicatedSetupDirty.has('auto-create-vote-sites') && autoSitesState.textContent === 'Not loaded'")); + assertTrue(script.body().contains("Configuration changed elsewhere; your unsaved guided edits were preserved."), + "External configuration changes must not overwrite unsaved guided edits."); + assertTrue(script.body().contains("dedicatedSetupDirty.add(preset);")); + assertTrue(script.body().contains("if (quickPreset.value !== 'vote-site') quickSetupDirty = true;"), + "The shared name field is a selector for vote sites but a dirty editable value for other presets."); assertTrue(script.body().contains("const profileName = profilePicker.value;")); assertTrue(script.body().contains("profilePicker.value !== profileName || !currentProfile || JSON.stringify(currentProfile) !== profileSignature"), "Profile application must verify its selection after waiting for live values."); @@ -241,7 +257,7 @@ class ControlHttpServerTest { assertTrue(script.body().contains("quickPresetNeedsRead() && !quickSetupValuesLoaded()")); assertFalse(script.body().contains("quickPresetReadable() && !loadedQuickSetup"), "Quick-setup autoload must re-read when the loaded vote-site selector changes."); - assertTrue(script.body().contains("quickPresetReadable() && !quickSetupValuesLoaded()"), + assertTrue(script.body().contains("quickPresetReadable() && (!quickSetupDirty || quickSetupPreserveReadGeneration === inputGeneration)"), "Quick-setup autoload must validate the loaded selector before deciding it is current."); assertTrue(script.body().contains("loadedQuickSetup.sessionId === nodeIndex.get(selectedServerId)?.sessionId")); assertTrue(script.body().contains("previousNodeIndex.get(selectedServerId)?.sessionId !== nodeIndex.get(selectedServerId)?.sessionId")); @@ -310,7 +326,7 @@ class ControlHttpServerTest { assertTrue(script.body().contains("if (previewGeneration !== inputGeneration\n" + " || signature !== JSON.stringify"), "Dedicated previews completed after another apply must not restore stale approvals."); - assertTrue(script.body().contains("dedicatedSetupApprovals.delete(field === autoSitesEnabled ? 'auto-create-vote-sites' : 'vote-logging');\n" + assertTrue(script.body().contains("dedicatedSetupApprovals.delete(preset);\n dedicatedSetupDirty.add(preset);\n" + " inputGeneration++;\n updateExtendedButtons();"), "Dedicated setup edits must fence delayed reads before they can overwrite newer input."); assertTrue(script.body().contains("const submittedOptions = JSON.stringify(dedicatedSetupOptions(preset));")); From 0418a076a0263a4fdef2d3321be007d5c161113b Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:34:51 -0600 Subject: [PATCH 10/19] Version Vote Party settings and refresh guided state --- README.md | 4 +++- docs/control-management.md | 6 ++++-- .../domain/ConfigurationOperations.java | 1 + .../control/domain/InMemoryNodeRegistry.java | 4 +++- .../protocol/ManagedConfiguration.java | 1 + src/main/resources/web/app.js | 20 ++++++++++++++++--- .../domain/ConfigurationOperationsTest.java | 8 ++++++++ .../domain/InMemoryNodeRegistryTest.java | 3 ++- .../control/http/ControlHttpServerTest.java | 13 ++++++++++-- 9 files changed, 50 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 945f11c7..6661279e 100644 --- a/README.md +++ b/README.md @@ -174,8 +174,10 @@ Configuration is split into independently negotiated capabilities. `config.proxy manages only an enrolled proxy's `bungeeconfig.yml`; it is not general proxy file access. The proxy file is strict, bounded, redacted YAML with safe preview/approval, atomic publication, and a local backup. General proxy-file settings are saved without a proxy reload, so the operation reports that a restart is needed to activate them. `config.quick-setup.v1` supplies standalone, -proxy-backend, vote-site, easy-reward, common-settings, auto-create-vote-sites, vote-logging, vote-party, and typed +proxy-backend, vote-site, easy-reward, common-settings, auto-create-vote-sites, vote-logging, and typed reward-builder presets. +Vote Party editing that includes the Enabled state requires `config.quick-setup.v2`; v1 nodes are never sent that +extended proposal. The auto-create preset owns only `AutoCreateVoteSites`; the logging preset owns only enabled state, purge retention (`-1` disables purging or `1`–`3650` days), and main-MySQL reuse, never connection credentials. Readable presets load their installed values before editing. diff --git a/docs/control-management.md b/docs/control-management.md index dbad3340..755c3a88 100644 --- a/docs/control-management.md +++ b/docs/control-management.md @@ -42,6 +42,7 @@ Control accepts only the intersection with its own allow-list. | `config.files.v1` | Bounded reads/previews/applies for managed Bukkit YAML files | | `config.file-comments.v1` | Preserves Control-managed comment metadata where supported | | `config.quick-setup.v1` | Typed guided settings and reward/site presets | +| `config.quick-setup.v2` | Vote Party guided settings including revision-safe Enabled round trips | | `config.vote-sites-sync.v1` | Reward-safe VoteSites merge from one backend to selected targets | | `config.transport-test.v1` | Typed, bounded proxy-to-backend communication check | | `config.proxy-method.v1` | Coordinated preview/apply and acknowledged runtime replacement for a supported network proxy method | @@ -129,8 +130,9 @@ general setting activation still requires a proxy restart. 4. Nodes stage and atomically replace managed YAML, reload VotingPlugin, and restore the local `.control-backup` if reload fails. The result distinguishes reload and rollback from a successful save. -The proxy method preset validates and persists the requested `MYSQL`, `PLUGINMESSAGING`, `REDIS`, `MQTT`, or `SOCKETS` -method on the proxy and its reported backends. The proxy acknowledges its durable result before replacing its runtime, so +The proxy method preset validates and persists the requested `MYSQL`, `PLUGINMESSAGING`, `REDIS`, `MQTT`, `SOCKETS`, or +`HTTP` method on the proxy and its reported backends. `HTTP` requires every target to negotiate +`config.proxy-method.v2`; the other methods use `config.proxy-method.v1`. The proxy acknowledges its durable result before replacing its runtime, so the operation result cannot be lost during teardown. Backends reload only their proxy communication handler. If a durable write or backend handler reload fails, its local backup is restored and the operation reports the failed/rolled-back state rather than a false network-wide success. diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java index e9372efb..400bf501 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java @@ -30,6 +30,7 @@ public final class ConfigurationOperations implements AutoCloseable { public static final String FILE_CAPABILITY = "config.files.v1"; public static final String PROXY_FILE_CAPABILITY = "config.proxy-files.v1"; public static final String QUICK_SETUP_CAPABILITY = "config.quick-setup.v1"; + public static final String QUICK_SETUP_VOTE_PARTY_CAPABILITY = "config.quick-setup.v2"; public static final String VOTE_SITES_SYNC_CAPABILITY = "config.vote-sites-sync.v1"; public static final String TRANSPORT_TEST_CAPABILITY = "config.transport-test.v1"; public static final String PROXY_METHOD_CAPABILITY = "config.proxy-method.v1"; diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java b/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java index 441e45f4..36087372 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java @@ -28,7 +28,9 @@ public final class InMemoryNodeRegistry implements NodeRegistry { public static final Set SUPPORTED_CAPABILITIES = Set.of("discovery.read", "presence.snapshot", ConfigurationOperations.CAPABILITY, ConfigurationOperations.FILE_CAPABILITY, ConfigurationOperations.PROXY_FILE_CAPABILITY, - ConfigurationOperations.QUICK_SETUP_CAPABILITY, ConfigurationOperations.VOTE_SITES_SYNC_CAPABILITY, + ConfigurationOperations.QUICK_SETUP_CAPABILITY, + ConfigurationOperations.QUICK_SETUP_VOTE_PARTY_CAPABILITY, + ConfigurationOperations.VOTE_SITES_SYNC_CAPABILITY, ConfigurationOperations.TRANSPORT_TEST_CAPABILITY, ConfigurationOperations.PROXY_METHOD_CAPABILITY, ConfigurationOperations.PROXY_METHOD_HTTP_CAPABILITY, "config.file-comments.v1", InspectionQuery.CAPABILITY); diff --git a/src/main/java/com/bencodez/votingplugin/control/protocol/ManagedConfiguration.java b/src/main/java/com/bencodez/votingplugin/control/protocol/ManagedConfiguration.java index 29889d18..5159db00 100644 --- a/src/main/java/com/bencodez/votingplugin/control/protocol/ManagedConfiguration.java +++ b/src/main/java/com/bencodez/votingplugin/control/protocol/ManagedConfiguration.java @@ -137,6 +137,7 @@ public String capability() { ? "HTTP".equals(options.get("method")) ? "config.proxy-method.v2" : "config.proxy-method.v1" : "proxy-backend".equals(preset) && "HTTP".equals(options.get("method")) ? "config.proxy-method.v2" + : "vote-party".equals(preset) ? "config.quick-setup.v2" : "config.quick-setup.v1"; default -> throw new IllegalStateException("unsupported configuration domain"); }; diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index 00a5182f..17f52c72 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -1334,6 +1334,7 @@ function friendlyCapability(capability) { 'config.proxy-method.v1': 'Proxy method', 'config.proxy-method.v2': 'Proxy method · HTTP', 'config.quick-setup.v1': 'Setup assistant', + 'config.quick-setup.v2': 'Vote Party setup', 'config.proxy-routing.v1': 'Proxy routing', 'data.inspect.v1': 'Read-only data inspection' })[capability]; @@ -1412,7 +1413,7 @@ function nodeCard(node) { const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; const controllable = ['config.proxy-routing.v1', 'config.files.v1', 'config.proxy-files.v1', - 'config.quick-setup.v1', 'config.proxy-method.v2'] + 'config.quick-setup.v1', 'config.quick-setup.v2', 'config.proxy-method.v2'] .some(capability => node.acceptedCapabilities.includes(capability)); checkbox.disabled = !node.online || !controllable || node.nodeId === selectedServerId; checkbox.checked = selectedNodes.has(node.nodeId); @@ -1491,7 +1492,8 @@ function updateHeaderAction(tab) { : tab === 'network' ? runNetworkDoctor.disabled : tab === 'configurations' ? runDriftCheck.disabled : tab === 'data' ? refreshDataOverview.disabled - : tab === 'quick-setup' ? !nodeCapabilities.get(selectedServerId)?.includes('config.quick-setup.v1') + : tab === 'quick-setup' ? !nodeCapabilities.get(selectedServerId)?.some(capability => + capability === 'config.quick-setup.v1' || capability === 'config.quick-setup.v2') : false; headerAction.disabled = !authenticated || unavailable; } @@ -2933,7 +2935,8 @@ function backendQuickTargets() { function quickSetupCapability() { return quickPreset.value === 'proxy-backend' && quickMethod.value === 'HTTP' - ? 'config.proxy-method.v2' : 'config.quick-setup.v1'; + ? 'config.proxy-method.v2' : quickPreset.value === 'vote-party' + ? 'config.quick-setup.v2' : 'config.quick-setup.v1'; } function quickSetupTargets() { @@ -3314,6 +3317,8 @@ async function waitForOperation(operation, statusElement = operationStatus, cont && Object.values(operation.results || {}).some(result => result?.success); if (applied) { invalidateConfigurationReads(); + invalidateGuidedSetupReads(); + if (tabFromHash() === 'quick-setup') window.setTimeout(() => void autoLoadTab('quick-setup'), 0); if (tabFromHash() === 'overview') { text(dataOverview, 'Configuration changed; refreshing server overview…'); window.setTimeout(() => void autoLoadTab('overview'), 0); @@ -3492,6 +3497,7 @@ async function loadNodesOnce() { ? node.detectedPlugins : []])); const selectedCapabilitiesChanged = [...selectedNodes].some(node => ['config.proxy-routing.v1', 'config.files.v1', 'config.proxy-files.v1', 'config.quick-setup.v1', + 'config.quick-setup.v2', 'data.inspect.v1'].some(capability => Boolean(previousCapabilities.get(node)?.includes(capability)) !== Boolean(nodeCapabilities.get(node)?.includes(capability)))); @@ -5070,6 +5076,7 @@ clearOperationHistory.addEventListener('click', loadOperationHistory); quickPartyAll, quickPartyOnline, quickAutoSitesOnly, quickVoteLoggingEnabled, quickVoteLoggingDays, quickVoteLoggingMainMysql].forEach(field => field.addEventListener('input', () => { quickSetupDirty = true; + exposeDirtyVoteSiteReload(); clearApprovals(); })); quickMethod.addEventListener('input', clearApprovals); @@ -5086,6 +5093,7 @@ quickMethod.addEventListener('input', () => { quickName.addEventListener('input', () => { if (pendingDetectedVoteSite && pendingDetectedVoteSite.key !== quickName.value.trim()) pendingDetectedVoteSite = null; updateQuickFields(); + exposeDirtyVoteSiteReload(); if (voteSiteReadTimer != null) window.clearTimeout(voteSiteReadTimer); if (quickPreset.value === 'vote-site' && quickPresetReadable()) { voteSiteReadTimer = window.setTimeout(() => { @@ -5094,6 +5102,12 @@ quickName.addEventListener('input', () => { }, 300); } }); + +function exposeDirtyVoteSiteReload() { + if (quickPreset.value !== 'vote-site' || !quickSetupDirty || quickSetupValuesLoaded()) return; + readQuickSetup.hidden = false; + text(quickOperationStatus, 'The vote-site key changed; load its current values to discard your unsaved edits.'); +} configurationContent.addEventListener('input', () => { if (!configurationDirty) { configurationDraftNodeId = selectedServerId; diff --git a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java index e2accf30..466344ed 100644 --- a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java @@ -823,6 +823,14 @@ class ConfigurationOperationsTest { null, List.of(), null, null, "standalone", Map.of("proposal", "x".repeat(501)))); } + @Test void votePartyEnabledRoundTripsRequireTheVersionedCapability() { + ManagedConfiguration voteParty = new ManagedConfiguration(ManagedConfiguration.QUICK_SETUP, null, List.of(), + null, null, "vote-party", Map.of("enabled", "false", "votesRequired", "20", + "broadcast", "", "giveAllPlayers", "false", "onlineOnly", "true", "command", "")); + + assertEquals(ConfigurationOperations.QUICK_SETUP_VOTE_PARTY_CAPABILITY, voteParty.capability()); + } + @Test void rewardBuilderResultDoesNotRetainOrEchoItsProposal() throws Exception { Clock clock = Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC); InMemoryNodeRegistry registry = new InMemoryNodeRegistry(clock, Duration.ofMinutes(2)); diff --git a/src/test/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistryTest.java b/src/test/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistryTest.java index a9cc6e2f..f00e0f9f 100644 --- a/src/test/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistryTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistryTest.java @@ -37,7 +37,8 @@ class InMemoryNodeRegistryTest { @Test void registrationNegotiatesOptionalCommentPreservingFileSupport() { Set capabilities = Set.of("config.files.v1", "config.file-comments.v1", - ConfigurationOperations.VOTE_SITES_SYNC_CAPABILITY); + ConfigurationOperations.VOTE_SITES_SYNC_CAPABILITY, + ConfigurationOperations.QUICK_SETUP_VOTE_PARTY_CAPABILITY); var result = registry.register(registration("backend-lobby", session, capabilities, Set.of("config.files.v1"))); diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java index 9ea5cb82..97d62425 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -133,6 +133,8 @@ class ControlHttpServerTest { assertTrue(script.body().contains("if (Object.hasOwn(profile, 'partyEnabled')) quickPartyEnabled.checked = Boolean(profile.partyEnabled);"), "Legacy v1 profiles must preserve the live Vote Party enabled state when they omit that field."); assertTrue(script.body().contains("config.proxy-method.v2")); + assertTrue(script.body().contains("quickPreset.value === 'vote-party'\n ? 'config.quick-setup.v2'"), + "Vote Party Enabled must never be sent under the incompatible v1 quick-setup contract."); assertTrue(script.body().contains("function quickSetupTargets()")); assertTrue(script.body().contains("nodeIds = quickSetupTargets()")); assertTrue(script.body().contains("currentNodeIds = sync ? selectedVoteSitesTargets() : quickSetupTargets()")); @@ -159,12 +161,14 @@ class ControlHttpServerTest { "Discarded automatic dedicated reads must request a fresh read rather than leave defaults previewable."); assertTrue(script.body().contains("if (observedSuccessfulApply) {\n invalidateConfigurationReads();\n invalidateGuidedSetupReads();"), "Observed external applies must invalidate the dedicated setup cards as well as the main editor."); + assertTrue(script.body().contains("if (applied) {\n invalidateConfigurationReads();\n invalidateGuidedSetupReads();"), + "Locally completed applies must invalidate guided reads as well as the main editor."); assertTrue(script.body().contains("if (tabFromHash() === 'quick-setup') window.setTimeout(() => void autoLoadTab('quick-setup'), 0);"), "Invalidated dedicated settings must automatically reload while Quick Setup is visible."); assertTrue(script.body().contains("nodeCapabilities.get(node)?.includes(quickSetupCapability())"), "Quick approvals must remain valid only for their selected capability version."); - assertTrue(script.body().contains("'config.quick-setup.v1', 'config.proxy-method.v2'"), - "Secondary v2-only backends must remain selectable for HTTP setup."); + assertTrue(script.body().contains("'config.quick-setup.v1', 'config.quick-setup.v2', 'config.proxy-method.v2'"), + "Secondary versioned backends must remain selectable for HTTP and Vote Party setup."); assertTrue(script.body().contains("quickPresetReadable() && (!quickSetupDirty || quickSetupPreserveReadGeneration === inputGeneration)")); assertTrue(script.body().contains("!dedicatedSetupDirty.has('auto-create-vote-sites') && autoSitesState.textContent === 'Not loaded'")); assertTrue(script.body().contains("Configuration changed elsewhere; your unsaved guided edits were preserved."), @@ -172,6 +176,11 @@ class ControlHttpServerTest { assertTrue(script.body().contains("dedicatedSetupDirty.add(preset);")); assertTrue(script.body().contains("if (quickPreset.value !== 'vote-site') quickSetupDirty = true;"), "The shared name field is a selector for vote sites but a dirty editable value for other presets."); + assertTrue(script.body().contains("function exposeDirtyVoteSiteReload()")); + assertTrue(script.body().contains("quickSetupDirty = true;\n exposeDirtyVoteSiteReload();"), + "Becoming dirty during the selector debounce must also expose reload."); + assertTrue(script.body().contains("The vote-site key changed; load its current values to discard your unsaved edits."), + "A dirty vote-site selector transition must expose an explicit discard and reload action."); assertTrue(script.body().contains("const profileName = profilePicker.value;")); assertTrue(script.body().contains("profilePicker.value !== profileName || !currentProfile || JSON.stringify(currentProfile) !== profileSignature"), "Profile application must verify its selection after waiting for live values."); From 982a8c51194a7cf02b17ac6aaec934be08b3b415 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:56:23 -0600 Subject: [PATCH 11/19] Preserve proxy method edits across reads --- src/main/resources/web/app.js | 3 ++- .../votingplugin/control/http/ControlHttpServerTest.java | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index 17f52c72..263a6a90 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -4070,7 +4070,7 @@ async function loadQuickSetupValues(automatic = false, preserveDirty = false) { } const detected = preset === 'vote-site' && pendingDetectedVoteSite?.nodeId === nodeId && pendingDetectedVoteSite.key === quickName.value.trim() ? pendingDetectedVoteSite : null; - const selectedProxyMethod = preset === 'proxy-backend' ? quickMethod.value : null; + const selectedProxyMethod = preserveDirty && preset === 'proxy-backend' ? quickMethod.value : null; const editedProxyServer = preserveDirty && preset === 'proxy-backend' ? quickName.value : null; populateQuickState(result.configuration.options); if (selectedProxyMethod != null) quickMethod.value = selectedProxyMethod; @@ -5086,6 +5086,7 @@ quickName.addEventListener('input', () => { }); quickMethod.addEventListener('input', () => { if (quickPreset.value === 'proxy-backend' && quickPresetReadable()) { + quickSetupDirty = true; if (quickSetupDirty) quickSetupPreserveReadGeneration = inputGeneration; void autoLoadTab('quick-setup'); } diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java index 97d62425..f9b5f992 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -147,9 +147,9 @@ class ControlHttpServerTest { assertTrue(script.body().contains("if (inputGeneration !== autoLoadGeneration) {\n autoLoadPending.add(tab);\n return;\n }"), "A stale dedicated read must fence the remainder of the automatic quick-setup sequence."); assertTrue(script.body().contains("quickMethod.addEventListener('input', () => {\n if (quickPreset.value === 'proxy-backend' && quickPresetReadable()) {")); - assertTrue(script.body().contains("if (quickSetupDirty) quickSetupPreserveReadGeneration = inputGeneration;\n void autoLoadTab('quick-setup');"), + assertTrue(script.body().contains("quickSetupDirty = true;\n if (quickSetupDirty) quickSetupPreserveReadGeneration = inputGeneration;\n void autoLoadTab('quick-setup');"), "Changing a proxy-backend method must schedule a capability-correct reread."); - assertTrue(script.body().contains("const selectedProxyMethod = preset === 'proxy-backend' ? quickMethod.value : null;")); + assertTrue(script.body().contains("const selectedProxyMethod = preserveDirty && preset === 'proxy-backend' ? quickMethod.value : null;")); assertTrue(script.body().contains("if (selectedProxyMethod != null) quickMethod.value = selectedProxyMethod;"), "The capability read must not overwrite the proxy method the operator selected for preview."); assertTrue(script.body().contains("const editedProxyServer = preserveDirty && preset === 'proxy-backend' ? quickName.value : null;")); From e8ee32b87f062a02f27752fc66a5990c65eaa27c Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sun, 13 Sep 2026 15:33:11 -0600 Subject: [PATCH 12/19] Harden automatic configuration state handling --- AGENTS.md | 15 +++++-- .../protocol/ManagedConfiguration.java | 2 +- src/main/resources/web/app.js | 39 ++++++++++++++----- .../domain/ConfigurationOperationsTest.java | 3 ++ .../control/http/ControlHttpServerTest.java | 22 ++++++++--- 5 files changed, 62 insertions(+), 19 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index be530aa6..293b3a1c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -134,10 +134,17 @@ when the pieces are independently deployable or need materially different review Protocol version `1` describes the registration/heartbeat resource protocol. Feature evolution normally uses a new capability such as `data.inspect.v1`; do not bump the whole protocol for an optional additive feature. -`config.proxy-method.v1` persists a validated method on the proxy and reported backends. Backends reload only their proxy -communication handler. The proxy acknowledges the durable result before its existing guarded runtime replacement, so -teardown cannot lose the completion. A failed backend reload must restore its backup. Control availability or a method -switch must never delay or prevent normal proxy or backend startup. +`config.proxy-method.v1` persists the legacy plugin-messaging or Redis method on the proxy and reported backends; +`config.proxy-method.v2` adds HTTP. Control must select the exact capability required by the requested method. Backends +reload only their proxy communication handler. The proxy acknowledges the durable result before its existing guarded +runtime replacement, so teardown cannot lose the completion. A failed backend reload must restore its backup. Control +availability or a method switch must never delay or prevent normal proxy or backend startup. + +`config.quick-setup.v2` adds `VoteParty.Enabled` to the versioned quick-setup contract. Control must keep legacy +Vote Party payloads on `config.quick-setup.v1` and require v2 before sending or accepting the `enabled` field. The +VotingPlugin connector change may deploy first and remain unused until Control negotiates v2; deploying Control first +must leave v2 actions unavailable on older nodes. Merge the VotingPlugin capability implementation before relying on the +new Control behavior in production. ## Safe change checklist diff --git a/src/main/java/com/bencodez/votingplugin/control/protocol/ManagedConfiguration.java b/src/main/java/com/bencodez/votingplugin/control/protocol/ManagedConfiguration.java index 5159db00..92fee5a8 100644 --- a/src/main/java/com/bencodez/votingplugin/control/protocol/ManagedConfiguration.java +++ b/src/main/java/com/bencodez/votingplugin/control/protocol/ManagedConfiguration.java @@ -137,7 +137,7 @@ public String capability() { ? "HTTP".equals(options.get("method")) ? "config.proxy-method.v2" : "config.proxy-method.v1" : "proxy-backend".equals(preset) && "HTTP".equals(options.get("method")) ? "config.proxy-method.v2" - : "vote-party".equals(preset) ? "config.quick-setup.v2" + : "vote-party".equals(preset) && options.containsKey("enabled") ? "config.quick-setup.v2" : "config.quick-setup.v1"; default -> throw new IllegalStateException("unsupported configuration domain"); }; diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index 263a6a90..eef1976f 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -1512,9 +1512,12 @@ function setActiveTab(tab, updateHash = false) { } function openWorkspace(tab, scrollTarget = '', preset = '', navigationButton = null) { - if (preset) { + if (preset && quickPreset.value !== preset) { quickPreset.value = preset; loadedQuickSetup = null; + quickSetupDirty = false; + quickSetupPreserveReadGeneration = -1; + pendingDetectedVoteSite = null; updateQuickFields(); clearApprovals(); } @@ -2935,10 +2938,19 @@ function backendQuickTargets() { function quickSetupCapability() { return quickPreset.value === 'proxy-backend' && quickMethod.value === 'HTTP' - ? 'config.proxy-method.v2' : quickPreset.value === 'vote-party' + ? 'config.proxy-method.v2' : quickPreset.value === 'vote-party' && votePartyUsesV2() ? 'config.quick-setup.v2' : 'config.quick-setup.v1'; } +function votePartyUsesV2() { + const selectedBackends = [...selectedNodes].filter(nodeId => nodeIndex.has(nodeId) + && isBackend(nodeIndex.get(nodeId)) + && (nodeCapabilities.get(nodeId)?.includes('config.quick-setup.v1') + || nodeCapabilities.get(nodeId)?.includes('config.quick-setup.v2'))); + return selectedBackends.length > 0 + && selectedBackends.every(nodeId => nodeCapabilities.get(nodeId)?.includes('config.quick-setup.v2')); +} + function quickSetupTargets() { return targets(quickSetupCapability()) .filter(nodeId => nodeIndex.has(nodeId) && isBackend(nodeIndex.get(nodeId))); @@ -3938,15 +3950,15 @@ applyFileConfiguration.addEventListener('click', async () => { || !window.confirm(`Apply this exact ${configurationFile.value} preview to ${fileTargetDescription()}?`)) return; const approval = approvedFilePreview; approvedFilePreview = null; - // Applying claims one generation; the successful-read invalidation claims the next. - const expectedApplyGeneration = inputGeneration + 2; + const submittedContent = configurationContent.value; try { const operation = await startConfigurationOperation('/api/v1/configuration/apply', { previewOperationId: approval.operationId, approvalToken: approval.approvalToken }, fileOperationStatus); const currentFileTargets = fileTargetsForSelection(configurationFile.value); - const submittedContextStillCurrent = inputGeneration === expectedApplyGeneration - && approval.fileName === configurationFile.value + const submittedContextStillCurrent = approval.fileName === configurationFile.value + && configurationContent.value === submittedContent + && fileDraftMatchesCurrentContext() && currentFileTargets.length === approval.nodeIds.length && approval.nodeIds.every(nodeId => currentFileTargets.includes(nodeId) && approval.sessions.get(nodeId) === nodeIndex.get(nodeId)?.sessionId); @@ -3989,10 +4001,12 @@ function quickOptions() { disableNoServiceSiteMessage: String(quickHideSiteWarning.checked), disableUpdateChecking: String(quickDisableUpdates.checked) }; - return {enabled: String(quickPartyEnabled.checked), votesRequired: quickPartyVotes.value, + const voteParty = {votesRequired: quickPartyVotes.value, command: quickPartyCommand.value.trim(), broadcast: quickPartyBroadcast.value.trim(), giveAllPlayers: String(quickPartyAll.checked), onlineOnly: String(quickPartyOnline.checked)}; + if (quickSetupCapability() === 'config.quick-setup.v2') voteParty.enabled = String(quickPartyEnabled.checked); + return voteParty; } function quickReadOptions() { @@ -4000,7 +4014,11 @@ function quickReadOptions() { } function quickReadConfigurationOptions() { - return quickPreset.value === 'proxy-backend' ? {method: quickMethod.value} : quickReadOptions(); + if (quickPreset.value === 'proxy-backend') return {method: quickMethod.value}; + if (quickPreset.value === 'vote-party' && quickSetupCapability() === 'config.quick-setup.v2') { + return {enabled: String(quickPartyEnabled.checked)}; + } + return quickReadOptions(); } function populateQuickState(options) { @@ -4030,7 +4048,10 @@ function populateQuickState(options) { quickVoteLoggingDays.value = options.purgeDays || '30'; quickVoteLoggingMainMysql.checked = options.useMainMySQL !== 'false'; } else if (quickPreset.value === 'vote-party') { - quickPartyEnabled.checked = options.enabled === 'true'; + const enabledAvailable = Object.hasOwn(options, 'enabled'); + quickPartyEnabled.checked = enabledAvailable && options.enabled === 'true'; + quickPartyEnabled.indeterminate = !enabledAvailable; + quickPartyEnabled.disabled = !enabledAvailable; quickPartyVotes.value = options.votesRequired || '20'; quickPartyBroadcast.value = options.broadcast || ''; quickPartyAll.checked = options.giveAllPlayers === 'true'; diff --git a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java index 466344ed..dbcbf37e 100644 --- a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java @@ -829,6 +829,9 @@ class ConfigurationOperationsTest { "broadcast", "", "giveAllPlayers", "false", "onlineOnly", "true", "command", "")); assertEquals(ConfigurationOperations.QUICK_SETUP_VOTE_PARTY_CAPABILITY, voteParty.capability()); + ManagedConfiguration legacyVoteParty = new ManagedConfiguration("quick-setup", null, null, + null, null, "vote-party", Map.of("votesRequired", "20")); + assertEquals(ConfigurationOperations.QUICK_SETUP_CAPABILITY, legacyVoteParty.capability()); } @Test void rewardBuilderResultDoesNotRetainOrEchoItsProposal() throws Exception { diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java index f9b5f992..316417cd 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -128,13 +128,19 @@ class ControlHttpServerTest { assertTrue(script.body().contains("readFileConfiguration.hidden = false;")); assertTrue(script.body().contains("readQuickSetup.hidden = false;")); assertTrue(script.body().contains("if (!quickSetupValuesLoaded()) {\n text(quickOperationStatus, 'The server or setup changed while reading.")); - assertTrue(script.body().contains("enabled: String(quickPartyEnabled.checked)")); - assertTrue(script.body().contains("quickPartyEnabled.checked = options.enabled === 'true'")); + assertTrue(script.body().contains("voteParty.enabled = String(quickPartyEnabled.checked)")); + assertTrue(script.body().contains("quickPartyEnabled.checked = enabledAvailable && options.enabled === 'true'")); assertTrue(script.body().contains("if (Object.hasOwn(profile, 'partyEnabled')) quickPartyEnabled.checked = Boolean(profile.partyEnabled);"), "Legacy v1 profiles must preserve the live Vote Party enabled state when they omit that field."); assertTrue(script.body().contains("config.proxy-method.v2")); - assertTrue(script.body().contains("quickPreset.value === 'vote-party'\n ? 'config.quick-setup.v2'"), + assertTrue(script.body().contains("quickPreset.value === 'vote-party' && votePartyUsesV2()\n" + + " ? 'config.quick-setup.v2'"), + "Vote Party must use v2 only when every selected backend supports it."); + assertTrue(script.body().contains("if (quickSetupCapability() === 'config.quick-setup.v2') voteParty.enabled"), "Vote Party Enabled must never be sent under the incompatible v1 quick-setup contract."); + assertTrue(script.body().contains("quickPartyEnabled.indeterminate = !enabledAvailable;\n" + + " quickPartyEnabled.disabled = !enabledAvailable;"), + "A legacy read must represent Enabled as unavailable instead of leaking another server's value."); assertTrue(script.body().contains("function quickSetupTargets()")); assertTrue(script.body().contains("nodeIds = quickSetupTargets()")); assertTrue(script.body().contains("currentNodeIds = sync ? selectedVoteSitesTargets() : quickSetupTargets()")); @@ -325,8 +331,9 @@ class ControlHttpServerTest { "Activity refreshes must invalidate cached health after observing an external successful apply."); assertTrue(script.body().contains("if (applied) {\n invalidateConfigurationReads();"), "Locally completed applies must use the same cache invalidation path."); - assertTrue(script.body().contains("const expectedApplyGeneration = inputGeneration + 2;")); - assertTrue(script.body().contains("const submittedContextStillCurrent = inputGeneration === expectedApplyGeneration")); + assertTrue(script.body().contains("const submittedContent = configurationContent.value;")); + assertTrue(script.body().contains("const submittedContextStillCurrent = approval.fileName === configurationFile.value\n" + + " && configurationContent.value === submittedContent\n && fileDraftMatchesCurrentContext()")); assertTrue(script.body().contains("approval.sessions.get(nodeId) === nodeIndex.get(nodeId)?.sessionId"), "File apply completion must compare input generation, target scope, file, and node sessions."); assertTrue(script.body().contains("The apply completed, but newer unsaved file edits remain. Preview again before applying them."), @@ -730,6 +737,11 @@ class ControlHttpServerTest { int activateAfterPreset = script.body().indexOf("setActiveTab(tab, true);", openWorkspace); assertTrue(openWorkspace >= 0 && presetBeforeTab > openWorkspace && activateAfterPreset > presetBeforeTab, "Nested shortcuts must establish their preset before tab autoload starts."); + assertTrue(script.body().contains("if (preset && quickPreset.value !== preset) {\n" + + " quickPreset.value = preset;\n loadedQuickSetup = null;\n" + + " quickSetupDirty = false;\n quickSetupPreserveReadGeneration = -1;\n" + + " pendingDetectedVoteSite = null;"), + "A shortcut replacing the preset must discard dirty state from the previous form before autoloading."); assertTrue(script.body().contains("if (autoLoadInFlight.has(tab)) {\n autoLoadPending.add(tab);")); assertTrue(script.body().contains("if (autoLoadPending.delete(tab)) void autoLoadTab(tab);"), "A preset change during an older read must queue a fresh autoload."); From 447bad61cf456d150208950b3be4a0b8ac292883 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:09:01 -0600 Subject: [PATCH 13/19] Refresh Vote Party state on target changes --- AGENTS.md | 2 +- src/main/resources/web/app.js | 17 +++++++++++++++++ .../control/http/ControlHttpServerTest.java | 5 +++++ 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 293b3a1c..79e6d4c2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -134,7 +134,7 @@ when the pieces are independently deployable or need materially different review Protocol version `1` describes the registration/heartbeat resource protocol. Feature evolution normally uses a new capability such as `data.inspect.v1`; do not bump the whole protocol for an optional additive feature. -`config.proxy-method.v1` persists the legacy plugin-messaging or Redis method on the proxy and reported backends; +`config.proxy-method.v1` persists the legacy plugin-messaging, Redis, MySQL, MQTT, or sockets method on the proxy and reported backends; `config.proxy-method.v2` adds HTTP. Control must select the exact capability required by the requested method. Backends reload only their proxy communication handler. The proxy acknowledges the durable result before its existing guarded runtime replacement, so teardown cannot lose the completion. A failed backend reload must restore its backup. Control diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index eef1976f..1302b938 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -1419,6 +1419,7 @@ function nodeCard(node) { checkbox.checked = selectedNodes.has(node.nodeId); if (node.nodeId === selectedServerId) selector.title = 'The primary server remains included in configuration changes.'; checkbox.addEventListener('change', () => { + const previousQuickCapability = quickSetupCapability(); if (checkbox.checked && selectedNodes.size >= MAX_CONFIGURATION_TARGETS) { checkbox.checked = false; text(operationStatus, `At most ${MAX_CONFIGURATION_TARGETS} servers can be configured at once.`); @@ -1430,6 +1431,7 @@ function nodeCard(node) { approvedQuickPreview = null; dedicatedSetupApprovals.clear(); inputGeneration++; + handleQuickTargetCapabilityChange(previousQuickCapability); updatePluginSuggestions(); renderSelectedServer(); updateConfigurationButtons(); @@ -2951,6 +2953,19 @@ function votePartyUsesV2() { && selectedBackends.every(nodeId => nodeCapabilities.get(nodeId)?.includes('config.quick-setup.v2')); } +function handleQuickTargetCapabilityChange(previousCapability) { + if (quickPreset.value !== 'vote-party' || previousCapability === quickSetupCapability()) return; + loadedQuickSetup = null; + approvedQuickPreview = null; + if (quickSetupDirty) { + readQuickSetup.hidden = false; + text(quickOperationStatus, + 'The selected backends require a different Vote Party capability. Retry loading to discard unsaved edits.'); + } else if (tabFromHash() === 'quick-setup') { + void autoLoadTab('quick-setup'); + } +} + function quickSetupTargets() { return targets(quickSetupCapability()) .filter(nodeId => nodeIndex.has(nodeId) && isBackend(nodeIndex.get(nodeId))); @@ -4460,6 +4475,7 @@ loadAutoSites.addEventListener('click', () => loadDedicatedSetup('auto-create-vo previewAutoSites.addEventListener('click', () => previewDedicatedSetup('auto-create-vote-sites')); applyAutoSites.addEventListener('click', () => applyDedicatedSetup('auto-create-vote-sites')); selectAllAutoSitesTargets.addEventListener('click', () => { + const previousQuickCapability = quickSetupCapability(); const available = allNodeItems.filter(node => isBackend(node) && node.online && node.acceptedCapabilities.includes('config.quick-setup.v1')); const candidates = available @@ -4471,6 +4487,7 @@ selectAllAutoSitesTargets.addEventListener('click', () => { approvedFilePreview = null; approvedQuickPreview = null; inputGeneration++; + handleQuickTargetCapabilityChange(previousQuickCapability); renderNodeViews(); updatePluginSuggestions(); updateConfigurationButtons(); diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java index 316417cd..9b099b0a 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -149,6 +149,11 @@ class ControlHttpServerTest { assertTrue(script.body().contains("function quickReadConfigurationOptions()")); assertTrue(script.body().contains("options: quickReadConfigurationOptions()")); assertTrue(script.body().contains("loadedQuickSetup.selector === JSON.stringify(quickReadConfigurationOptions())")); + assertTrue(script.body().contains("function handleQuickTargetCapabilityChange(previousCapability)")); + assertTrue(script.body().contains("The selected backends require a different Vote Party capability."), + "A target-driven v1/v2 change must expose an explicit reload when dirty."); + assertTrue(script.body().contains("handleQuickTargetCapabilityChange(previousQuickCapability);"), + "Target selection changes must reload or expose the capability-correct Vote Party read."); assertTrue(script.body().contains("const autoLoadGeneration = inputGeneration;")); assertTrue(script.body().contains("if (inputGeneration !== autoLoadGeneration) {\n autoLoadPending.add(tab);\n return;\n }"), "A stale dedicated read must fence the remainder of the automatic quick-setup sequence."); From 0d3a357714e0e7800ad1d9cf1d123059dc7f8921 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:31:48 -0600 Subject: [PATCH 14/19] Preserve guided setup state across reads --- README.md | 6 +++--- src/main/resources/web/app.js | 6 ++++-- .../votingplugin/control/http/ControlHttpServerTest.java | 9 ++++++++- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 6661279e..67cd4435 100644 --- a/README.md +++ b/README.md @@ -174,10 +174,10 @@ Configuration is split into independently negotiated capabilities. `config.proxy manages only an enrolled proxy's `bungeeconfig.yml`; it is not general proxy file access. The proxy file is strict, bounded, redacted YAML with safe preview/approval, atomic publication, and a local backup. General proxy-file settings are saved without a proxy reload, so the operation reports that a restart is needed to activate them. `config.quick-setup.v1` supplies standalone, -proxy-backend, vote-site, easy-reward, common-settings, auto-create-vote-sites, vote-logging, and typed +proxy-backend, vote-site, easy-reward, common-settings, vote-party, auto-create-vote-sites, vote-logging, and typed reward-builder presets. -Vote Party editing that includes the Enabled state requires `config.quick-setup.v2`; v1 nodes are never sent that -extended proposal. +Legacy Vote Party editing remains available through v1; only its Enabled extension requires +`config.quick-setup.v2`, and v1 nodes are never sent that extended proposal. The auto-create preset owns only `AutoCreateVoteSites`; the logging preset owns only enabled state, purge retention (`-1` disables purging or `1`–`3650` days), and main-MySQL reuse, never connection credentials. Readable presets load their installed values before editing. diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index 1302b938..2e37fdc1 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -3504,6 +3504,7 @@ async function loadNodesOnce() { try { const registry = await loadAllNodes(); const previousNodeIndex = nodeIndex; + const previousQuickCapability = quickSetupCapability(); allNodeItems = registry.items; nodePageMetadata = registry.pageMetadata; selectNodePage(pageOffset); @@ -3586,6 +3587,7 @@ async function loadNodesOnce() { text(operationStatus, routingDraftStatus('The selected nodes changed during refresh. Preview again before apply.')); } selectedNodes = filteredSelection; + handleQuickTargetCapabilityChange(previousQuickCapability); renderNodeViews(); updatePluginSuggestions(); updateConfigurationButtons(); @@ -4031,7 +4033,7 @@ function quickReadOptions() { function quickReadConfigurationOptions() { if (quickPreset.value === 'proxy-backend') return {method: quickMethod.value}; if (quickPreset.value === 'vote-party' && quickSetupCapability() === 'config.quick-setup.v2') { - return {enabled: String(quickPartyEnabled.checked)}; + return {enabled: 'true'}; } return quickReadOptions(); } @@ -5077,7 +5079,7 @@ loadProfile.addEventListener('click', async () => { updateQuickFields(); clearApprovals(); text(profileStatus, `Loading live values before applying “${profileName}”…`); - if (quickPresetReadable() && !await loadQuickSetupValues(true)) { + if (quickPresetReadable() && !await loadQuickSetupValues(true, true)) { text(profileStatus, `Could not load live values for “${profileName}”. Retry before using this profile.`); return; } diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java index 9b099b0a..482f51d7 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -181,6 +181,13 @@ class ControlHttpServerTest { assertTrue(script.body().contains("'config.quick-setup.v1', 'config.quick-setup.v2', 'config.proxy-method.v2'"), "Secondary versioned backends must remain selectable for HTTP and Vote Party setup."); assertTrue(script.body().contains("quickPresetReadable() && (!quickSetupDirty || quickSetupPreserveReadGeneration === inputGeneration)")); + assertTrue(script.body().contains("return {enabled: 'true'};"), + "Vote Party v2 reads must not use the editable Enabled value as their selector."); + assertTrue(script.body().contains("loadQuickSetupValues(true, true)"), + "Profile loading must preserve its requested proxy method through the live read."); + assertTrue(script.body().contains("const previousQuickCapability = quickSetupCapability();") + && script.body().contains("selectedNodes = filteredSelection;\n handleQuickTargetCapabilityChange(previousQuickCapability);"), + "Heartbeat capability changes must invalidate or re-expose Vote Party loading."); assertTrue(script.body().contains("!dedicatedSetupDirty.has('auto-create-vote-sites') && autoSitesState.textContent === 'Not loaded'")); assertTrue(script.body().contains("Configuration changed elsewhere; your unsaved guided edits were preserved."), "External configuration changes must not overwrite unsaved guided edits."); @@ -750,7 +757,7 @@ class ControlHttpServerTest { assertTrue(script.body().contains("if (autoLoadInFlight.has(tab)) {\n autoLoadPending.add(tab);")); assertTrue(script.body().contains("if (autoLoadPending.delete(tab)) void autoLoadTab(tab);"), "A preset change during an older read must queue a fresh autoload."); - assertTrue(script.body().contains("quickPresetReadable() && !await loadQuickSetupValues(true)"), + assertTrue(script.body().contains("quickPresetReadable() && !await loadQuickSetupValues(true, true)"), "Loading a saved profile must read live values before enabling the template."); assertTrue(script.body().contains("applyProfileValues(profile);"), "The saved template must be restored after the live read rather than overwritten by it."); From 6c421870f456a15a78ad16594aa79a72447b63c1 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:53:37 -0600 Subject: [PATCH 15/19] Reject mixed Vote Party capability targets --- src/main/resources/web/app.js | 20 +++++++++++-------- .../control/http/ControlHttpServerTest.java | 11 +++++++--- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index 2e37fdc1..d0b87893 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -2940,17 +2940,21 @@ function backendQuickTargets() { function quickSetupCapability() { return quickPreset.value === 'proxy-backend' && quickMethod.value === 'HTTP' - ? 'config.proxy-method.v2' : quickPreset.value === 'vote-party' && votePartyUsesV2() - ? 'config.quick-setup.v2' : 'config.quick-setup.v1'; + ? 'config.proxy-method.v2' : quickPreset.value === 'vote-party' + ? votePartyCapability() : 'config.quick-setup.v1'; } -function votePartyUsesV2() { +function votePartyCapability() { const selectedBackends = [...selectedNodes].filter(nodeId => nodeIndex.has(nodeId) - && isBackend(nodeIndex.get(nodeId)) - && (nodeCapabilities.get(nodeId)?.includes('config.quick-setup.v1') - || nodeCapabilities.get(nodeId)?.includes('config.quick-setup.v2'))); - return selectedBackends.length > 0 - && selectedBackends.every(nodeId => nodeCapabilities.get(nodeId)?.includes('config.quick-setup.v2')); + && isBackend(nodeIndex.get(nodeId))); + if (selectedBackends.length === 0) return null; + if (selectedBackends.every(nodeId => nodeCapabilities.get(nodeId)?.includes('config.quick-setup.v2'))) { + return 'config.quick-setup.v2'; + } + if (selectedBackends.every(nodeId => nodeCapabilities.get(nodeId)?.includes('config.quick-setup.v1'))) { + return 'config.quick-setup.v1'; + } + return null; } function handleQuickTargetCapabilityChange(previousCapability) { diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java index 482f51d7..14790950 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -133,15 +133,20 @@ class ControlHttpServerTest { assertTrue(script.body().contains("if (Object.hasOwn(profile, 'partyEnabled')) quickPartyEnabled.checked = Boolean(profile.partyEnabled);"), "Legacy v1 profiles must preserve the live Vote Party enabled state when they omit that field."); assertTrue(script.body().contains("config.proxy-method.v2")); - assertTrue(script.body().contains("quickPreset.value === 'vote-party' && votePartyUsesV2()\n" - + " ? 'config.quick-setup.v2'"), - "Vote Party must use v2 only when every selected backend supports it."); + assertTrue(script.body().contains("quickPreset.value === 'vote-party'\n" + + " ? votePartyCapability()"), + "Vote Party must select only a capability shared by every selected backend."); assertTrue(script.body().contains("if (quickSetupCapability() === 'config.quick-setup.v2') voteParty.enabled"), "Vote Party Enabled must never be sent under the incompatible v1 quick-setup contract."); assertTrue(script.body().contains("quickPartyEnabled.indeterminate = !enabledAvailable;\n" + " quickPartyEnabled.disabled = !enabledAvailable;"), "A legacy read must represent Enabled as unavailable instead of leaking another server's value."); assertTrue(script.body().contains("function quickSetupTargets()")); + assertTrue(script.body().contains("function votePartyCapability()")); + assertTrue(script.body().contains("if (selectedBackends.length === 0) return null;")); + assertTrue(script.body().contains("selectedBackends.every(nodeId => nodeCapabilities.get(nodeId)?.includes('config.quick-setup.v1'))")); + assertTrue(script.body().contains("return null;\n}"), + "Mixed Vote Party capability sets must be rejected instead of silently dropping selected backends."); assertTrue(script.body().contains("nodeIds = quickSetupTargets()")); assertTrue(script.body().contains("currentNodeIds = sync ? selectedVoteSitesTargets() : quickSetupTargets()")); assertTrue(script.body().contains("autoLoadPending.add(tab);")); From 64380bf9d43941e83d85086e2ebb252dde41c50b Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sun, 13 Sep 2026 18:19:12 -0600 Subject: [PATCH 16/19] Invalidate capability-bound proxy reads --- src/main/resources/web/app.js | 19 +++++++++++++++++-- .../control/http/ControlHttpServerTest.java | 8 ++++++-- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index d0b87893..c815309c 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -265,6 +265,7 @@ let proxyMethodProxyId = ''; let proxyMethodCurrentFor = ''; let proxyMethodCurrentSessionId = ''; let proxyMethodCurrentValue = ''; +let proxyMethodReadGeneration = 0; let nodeCapabilities = new Map(); let nodePlugins = new Map(); let inputGeneration = 0; @@ -3527,13 +3528,25 @@ async function loadNodesOnce() { nodeCapabilities = new Map(registry.items.map(node => [node.nodeId, node.online ? node.acceptedCapabilities : []])); nodePlugins = new Map(registry.items.map(node => [node.nodeId, node.online && Array.isArray(node.detectedPlugins) ? node.detectedPlugins : []])); + const proxyMethodCapabilityNodes = new Set([...selectedNodes, proxyMethodProxyId].filter(Boolean)); + const proxyMethodCapabilitiesChanged = [...proxyMethodCapabilityNodes].some(node => + ['config.proxy-method.v1', 'config.proxy-method.v2'].some(capability => + Boolean(previousCapabilities.get(node)?.includes(capability)) !== + Boolean(nodeCapabilities.get(node)?.includes(capability)))); const selectedCapabilitiesChanged = [...selectedNodes].some(node => ['config.proxy-routing.v1', 'config.files.v1', 'config.proxy-files.v1', 'config.quick-setup.v1', - 'config.quick-setup.v2', + 'config.quick-setup.v2', 'config.proxy-method.v1', 'config.proxy-method.v2', 'data.inspect.v1'].some(capability => Boolean(previousCapabilities.get(node)?.includes(capability)) !== Boolean(nodeCapabilities.get(node)?.includes(capability)))); - if (selectedCapabilitiesChanged) { + if (selectedCapabilitiesChanged || proxyMethodCapabilitiesChanged) { + invalidateGuidedSetupReads(); + if (proxyMethodCapabilitiesChanged) { + proxyMethodReadGeneration++; + proxyMethodCurrentFor = ''; + proxyMethodCurrentSessionId = ''; + proxyMethodCurrentValue = ''; + } approvedPreview = null; approvedFilePreview = null; approvedQuickPreview = null; @@ -4275,6 +4288,7 @@ runTransportTest.addEventListener('click', async () => { async function loadProxyMethod(automatic = false) { const proxyId = proxyMethodProxyId; const readCapability = proxyMethodReadCapability(); + const readGeneration = proxyMethodReadGeneration; const sessionId = proxyMethodNetwork(readCapability).proxy?.sessionId; const requestAuthenticationGeneration = authenticationGeneration; if (!proxyId) return; @@ -4288,6 +4302,7 @@ async function loadProxyMethod(automatic = false) { const method = result?.success ? result.configuration?.options?.method : ''; if (!method) throw new Error('The proxy did not return its active communication method.'); if (requestAuthenticationGeneration !== authenticationGeneration || proxyId !== proxyMethodProxyId + || readGeneration !== proxyMethodReadGeneration || readCapability !== proxyMethodReadCapability() || sessionId !== proxyMethodNetwork(readCapability).proxy?.sessionId || result?.sessionId !== sessionId) return; proxyMethodCurrentFor = proxyId; proxyMethodCurrentSessionId = sessionId; diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java index 14790950..50acfd50 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -143,6 +143,11 @@ class ControlHttpServerTest { "A legacy read must represent Enabled as unavailable instead of leaking another server's value."); assertTrue(script.body().contains("function quickSetupTargets()")); assertTrue(script.body().contains("function votePartyCapability()")); + assertTrue(script.body().contains("'config.quick-setup.v2', 'config.proxy-method.v1', 'config.proxy-method.v2'")); + assertTrue(script.body().contains("if (selectedCapabilitiesChanged || proxyMethodCapabilitiesChanged) {\n invalidateGuidedSetupReads();")); + assertTrue(script.body().contains("const proxyMethodCapabilityNodes = new Set([...selectedNodes, proxyMethodProxyId].filter(Boolean));")); + assertTrue(script.body().contains("if (proxyMethodCapabilitiesChanged) {\n proxyMethodReadGeneration++;")); + assertTrue(script.body().contains("readGeneration !== proxyMethodReadGeneration || readCapability !== proxyMethodReadCapability()")); assertTrue(script.body().contains("if (selectedBackends.length === 0) return null;")); assertTrue(script.body().contains("selectedBackends.every(nodeId => nodeCapabilities.get(nodeId)?.includes('config.quick-setup.v1'))")); assertTrue(script.body().contains("return null;\n}"), @@ -387,8 +392,7 @@ class ControlHttpServerTest { assertTrue(script.body().contains("window.addEventListener('beforeunload'")); assertTrue(script.body().contains("loadedQuickSetup = {nodeId, sessionId, preset,")); assertTrue(script.body().contains("configurationOperationsInFlight")); - assertTrue(script.body().contains("if (selectedCapabilitiesChanged) {\n approvedPreview = null;")); - assertTrue(script.body().contains("approvedPreview.nodeIds.every")); + assertTrue(script.body().contains("approvedPreview.nodeIds.every")); assertTrue(script.body().contains("selectedCapabilitiesChanged")); assertTrue(script.body().contains("proxyFile ? !isProxy(restoreNode) : !isBackend(restoreNode)")); assertTrue(script.body().contains("discardAuthenticationState")); From c8f566f079e8186f1cba5b413122011f4e231e99 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sun, 13 Sep 2026 18:40:38 -0600 Subject: [PATCH 17/19] Fix mixed-version configuration capability handling --- .../domain/ConfigurationOperations.java | 3 +- src/main/resources/web/app.js | 15 +++++-- .../domain/ConfigurationOperationsTest.java | 40 +++++++++++++++++++ .../control/http/ControlHttpServerTest.java | 5 ++- 4 files changed, 57 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java index 400bf501..96d9ba71 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java @@ -704,7 +704,8 @@ private static void validateResultConfiguration(StoredOperation operation, Confi ManagedConfiguration expected = operation.configuration; boolean mismatch = expected == null || !expected.domain().equals(actual.domain()) || (ManagedConfiguration.FILE.equals(expected.domain()) && !expected.fileName().equals(actual.fileName())) - || (ManagedConfiguration.QUICK_SETUP.equals(expected.domain()) && !expected.preset().equals(actual.preset())); + || (ManagedConfiguration.QUICK_SETUP.equals(expected.domain()) && !expected.preset().equals(actual.preset())) + || (!"READ".equals(operation.type) && !expected.capability().equals(actual.capability())); if (mismatch) throw invalid("result configuration does not match the operation selector"); } diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index c815309c..08a5f1a1 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -2940,9 +2940,18 @@ function backendQuickTargets() { } function quickSetupCapability() { - return quickPreset.value === 'proxy-backend' && quickMethod.value === 'HTTP' - ? 'config.proxy-method.v2' : quickPreset.value === 'vote-party' - ? votePartyCapability() : 'config.quick-setup.v1'; + if (quickPreset.value === 'proxy-backend') { + const capability = quickMethod.value === 'HTTP' ? 'config.proxy-method.v2' : 'config.quick-setup.v1'; + return selectedBackendsSupport(capability) ? capability : null; + } + return quickPreset.value === 'vote-party' ? votePartyCapability() : 'config.quick-setup.v1'; +} + +function selectedBackendsSupport(capability) { + const selectedBackends = [...selectedNodes].filter(nodeId => nodeIndex.has(nodeId) + && isBackend(nodeIndex.get(nodeId))); + return selectedBackends.length > 0 + && selectedBackends.every(nodeId => nodeCapabilities.get(nodeId)?.includes(capability)); } function votePartyCapability() { diff --git a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java index dbcbf37e..38f55e6d 100644 --- a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java @@ -116,6 +116,46 @@ class ConfigurationOperationsTest { assertEquals("false", read.results().get("lobby").configuration().options().get("processRewards")); } + @Test void quickSetupReadAcceptsInstalledStateFromANewerCapability() throws Exception { + Clock clock = Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC); + InMemoryNodeRegistry registry = new InMemoryNodeRegistry(clock, Duration.ofMinutes(2)); + UUID session = UUID.randomUUID(); + registry.register(new NodeRegistration("lobby", session, "Lobby", "BUKKIT", "test", 1, + Set.of(ConfigurationOperations.QUICK_SETUP_CAPABILITY), Set.of())); + ConfigurationOperations operations = new ConfigurationOperations(registry, + new ConfigurationAuditLog(directory, clock), clock); + ManagedConfiguration selector = new ManagedConfiguration(ManagedConfiguration.QUICK_SETUP, null, List.of(), + null, null, "vote-party", Map.of()); + ConfigurationOperations.OperationView read = operations.createRead(List.of("lobby"), selector); + ConfigurationTask task = operations.claim("lobby", session); + ManagedConfiguration v2 = new ManagedConfiguration(ManagedConfiguration.QUICK_SETUP, null, List.of(), + null, null, "vote-party", Map.of("enabled", "true")); + read = operations.complete(read.operationId(), "lobby", + new ConfigurationTaskResult(session, true, "OK", "installed", "a".repeat(64), v2, + List.of(), false, false, task.attemptId())); + assertEquals("SUCCEEDED", read.state()); + assertEquals("true", read.results().get("lobby").configuration().options().get("enabled")); + } + + @Test void quickSetupPreviewRejectsAResultFromANewerCapability() throws Exception { + Clock clock = Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC); + InMemoryNodeRegistry registry = new InMemoryNodeRegistry(clock, Duration.ofMinutes(2)); + UUID session = UUID.randomUUID(); + registry.register(new NodeRegistration("lobby", session, "Lobby", "BUKKIT", "test", 1, + Set.of(ConfigurationOperations.QUICK_SETUP_CAPABILITY), Set.of())); + ConfigurationOperations operations = new ConfigurationOperations(registry, + new ConfigurationAuditLog(directory, clock), clock); + ManagedConfiguration v1 = new ManagedConfiguration(ManagedConfiguration.QUICK_SETUP, null, List.of(), + null, null, "vote-party", Map.of("votesRequired", "100")); + ConfigurationOperations.OperationView preview = operations.createPreview(List.of("lobby"), v1); + ConfigurationTask task = operations.claim("lobby", session); + ManagedConfiguration v2 = new ManagedConfiguration(ManagedConfiguration.QUICK_SETUP, null, List.of(), + null, null, "vote-party", Map.of("enabled", "true", "votesRequired", "100")); + assertThrows(ValidationException.class, () -> operations.complete(preview.operationId(), "lobby", + new ConfigurationTaskResult(session, true, "OK", "previewed", "a".repeat(64), v2, + List.of(), false, false, task.attemptId()))); + } + @Test void voteLoggingAppliesAndRetriesAreSerializedPerTarget() throws Exception { Clock clock = Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC); InMemoryNodeRegistry registry = new InMemoryNodeRegistry(clock, Duration.ofMinutes(2)); diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java index 50acfd50..1ded9177 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -133,8 +133,7 @@ class ControlHttpServerTest { assertTrue(script.body().contains("if (Object.hasOwn(profile, 'partyEnabled')) quickPartyEnabled.checked = Boolean(profile.partyEnabled);"), "Legacy v1 profiles must preserve the live Vote Party enabled state when they omit that field."); assertTrue(script.body().contains("config.proxy-method.v2")); - assertTrue(script.body().contains("quickPreset.value === 'vote-party'\n" - + " ? votePartyCapability()"), + assertTrue(script.body().contains("return quickPreset.value === 'vote-party' ? votePartyCapability()"), "Vote Party must select only a capability shared by every selected backend."); assertTrue(script.body().contains("if (quickSetupCapability() === 'config.quick-setup.v2') voteParty.enabled"), "Vote Party Enabled must never be sent under the incompatible v1 quick-setup contract."); @@ -143,6 +142,8 @@ class ControlHttpServerTest { "A legacy read must represent Enabled as unavailable instead of leaking another server's value."); assertTrue(script.body().contains("function quickSetupTargets()")); assertTrue(script.body().contains("function votePartyCapability()")); + assertTrue(script.body().contains("return selectedBackendsSupport(capability) ? capability : null;")); + assertTrue(script.body().contains("function selectedBackendsSupport(capability)")); assertTrue(script.body().contains("'config.quick-setup.v2', 'config.proxy-method.v1', 'config.proxy-method.v2'")); assertTrue(script.body().contains("if (selectedCapabilitiesChanged || proxyMethodCapabilitiesChanged) {\n invalidateGuidedSetupReads();")); assertTrue(script.body().contains("const proxyMethodCapabilityNodes = new Set([...selectedNodes, proxyMethodProxyId].filter(Boolean));")); From 7b04207c9629f464e30317d1d935d0e6c521b370 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sun, 13 Sep 2026 18:51:52 -0600 Subject: [PATCH 18/19] Reset guided state for detected vote sites --- src/main/resources/web/app.js | 4 +++- .../votingplugin/control/http/ControlHttpServerTest.java | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index 08a5f1a1..6bd0e5fd 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -637,6 +637,8 @@ function renderSiteHealthResult(value) { button.className = 'secondary compact'; button.addEventListener('click', () => { const key = String(service).replace(/[^A-Za-z0-9_-]/g, '-').replace(/-+/g, '-').slice(0, 64) || 'vote-site'; + quickSetupDirty = false; + quickSetupPreserveReadGeneration = -1; quickPreset.value = 'vote-site'; quickName.value = key; quickSiteDisplayName.value = String(service).slice(0, 200); @@ -649,7 +651,7 @@ function renderSiteHealthResult(value) { renderNodeViews(); updatePluginSuggestions(); setActiveTab('quick-setup', true); - text(quickOperationStatus, 'Detected service copied into the VoteSite setup. Load the generated key to confirm it is unused, complete the URL and delay, then preview before creating it.'); + text(quickOperationStatus, 'Detected service copied into the VoteSite setup. Its generated key is loading automatically; complete the URL and delay, then preview before creating it.'); scrollToAnchor(document.querySelector('#quick-setup-card')); }); actions.append(button); diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java index 1ded9177..7048a653 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -205,6 +205,8 @@ class ControlHttpServerTest { assertTrue(script.body().contains("dedicatedSetupDirty.add(preset);")); assertTrue(script.body().contains("if (quickPreset.value !== 'vote-site') quickSetupDirty = true;"), "The shared name field is a selector for vote sites but a dirty editable value for other presets."); + assertTrue(script.body().contains("quickSetupDirty = false;\n quickSetupPreserveReadGeneration = -1;\n quickPreset.value = 'vote-site';"), + "Opening a detected vote site must clear another preset's dirty guard before automatic READ."); assertTrue(script.body().contains("function exposeDirtyVoteSiteReload()")); assertTrue(script.body().contains("quickSetupDirty = true;\n exposeDirtyVoteSiteReload();"), "Becoming dirty during the selector debounce must also expose reload."); From 2279a4329caae67909e78db5c77f6f307a5fcea3 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sun, 13 Sep 2026 19:08:03 -0600 Subject: [PATCH 19/19] Refresh invalidated configuration reads --- .../domain/ConfigurationOperations.java | 8 +++++- src/main/resources/web/app.js | 1 + .../domain/ConfigurationOperationsTest.java | 27 ++++++++++++++++--- .../control/http/ControlHttpServerTest.java | 3 +++ 4 files changed, 34 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java index 96d9ba71..ee3e0be8 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java @@ -705,10 +705,16 @@ private static void validateResultConfiguration(StoredOperation operation, Confi boolean mismatch = expected == null || !expected.domain().equals(actual.domain()) || (ManagedConfiguration.FILE.equals(expected.domain()) && !expected.fileName().equals(actual.fileName())) || (ManagedConfiguration.QUICK_SETUP.equals(expected.domain()) && !expected.preset().equals(actual.preset())) - || (!"READ".equals(operation.type) && !expected.capability().equals(actual.capability())); + || (!activeMethodRead(operation, expected) && !expected.capability().equals(actual.capability())); if (mismatch) throw invalid("result configuration does not match the operation selector"); } + private static boolean activeMethodRead(StoredOperation operation, ManagedConfiguration expected) { + return "READ".equals(operation.type) && ManagedConfiguration.QUICK_SETUP.equals(expected.domain()) + && (ManagedConfiguration.PROXY_METHOD.equals(expected.preset()) + || "proxy-backend".equals(expected.preset())); + } + private String retainMessage(String message) { int remaining = (int) Math.max(0, MAX_RETAINED_MESSAGE_BYTES - retainedMessageBytes); String retained = truncateUtf8(message, remaining); diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index 6bd0e5fd..e5f071ad 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -3334,6 +3334,7 @@ function invalidateConfigurationReads() { configurationContent.value = ''; configurationContentPresent = false; text(fileOperationStatus, 'Configuration changed; read the current file before previewing changes.'); + if (tabFromHash() === 'configurations') window.setTimeout(() => void autoLoadTab('configurations'), 0); } lastOverview = null; lastDiagnostics = null; diff --git a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java index 38f55e6d..b2911c76 100644 --- a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java @@ -116,7 +116,7 @@ class ConfigurationOperationsTest { assertEquals("false", read.results().get("lobby").configuration().options().get("processRewards")); } - @Test void quickSetupReadAcceptsInstalledStateFromANewerCapability() throws Exception { + @Test void proxyBackendReadAcceptsInstalledMethodFromANewerCapability() throws Exception { Clock clock = Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC); InMemoryNodeRegistry registry = new InMemoryNodeRegistry(clock, Duration.ofMinutes(2)); UUID session = UUID.randomUUID(); @@ -125,16 +125,16 @@ class ConfigurationOperationsTest { ConfigurationOperations operations = new ConfigurationOperations(registry, new ConfigurationAuditLog(directory, clock), clock); ManagedConfiguration selector = new ManagedConfiguration(ManagedConfiguration.QUICK_SETUP, null, List.of(), - null, null, "vote-party", Map.of()); + null, null, "proxy-backend", Map.of()); ConfigurationOperations.OperationView read = operations.createRead(List.of("lobby"), selector); ConfigurationTask task = operations.claim("lobby", session); ManagedConfiguration v2 = new ManagedConfiguration(ManagedConfiguration.QUICK_SETUP, null, List.of(), - null, null, "vote-party", Map.of("enabled", "true")); + null, null, "proxy-backend", Map.of("method", "HTTP")); read = operations.complete(read.operationId(), "lobby", new ConfigurationTaskResult(session, true, "OK", "installed", "a".repeat(64), v2, List.of(), false, false, task.attemptId())); assertEquals("SUCCEEDED", read.state()); - assertEquals("true", read.results().get("lobby").configuration().options().get("enabled")); + assertEquals("HTTP", read.results().get("lobby").configuration().options().get("method")); } @Test void quickSetupPreviewRejectsAResultFromANewerCapability() throws Exception { @@ -156,6 +156,25 @@ class ConfigurationOperationsTest { List.of(), false, false, task.attemptId()))); } + @Test void votePartyReadRejectsAResultFromAnUnnegotiatedCapability() throws Exception { + Clock clock = Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC); + InMemoryNodeRegistry registry = new InMemoryNodeRegistry(clock, Duration.ofMinutes(2)); + UUID session = UUID.randomUUID(); + registry.register(new NodeRegistration("lobby", session, "Lobby", "BUKKIT", "test", 1, + Set.of(ConfigurationOperations.QUICK_SETUP_CAPABILITY), Set.of())); + ConfigurationOperations operations = new ConfigurationOperations(registry, + new ConfigurationAuditLog(directory, clock), clock); + ManagedConfiguration selector = new ManagedConfiguration(ManagedConfiguration.QUICK_SETUP, null, List.of(), + null, null, "vote-party", Map.of()); + ConfigurationOperations.OperationView read = operations.createRead(List.of("lobby"), selector); + ConfigurationTask task = operations.claim("lobby", session); + ManagedConfiguration v2 = new ManagedConfiguration(ManagedConfiguration.QUICK_SETUP, null, List.of(), + null, null, "vote-party", Map.of("enabled", "true")); + assertThrows(ValidationException.class, () -> operations.complete(read.operationId(), "lobby", + new ConfigurationTaskResult(session, true, "OK", "installed", "a".repeat(64), v2, + List.of(), false, false, task.attemptId()))); + } + @Test void voteLoggingAppliesAndRetriesAreSerializedPerTarget() throws Exception { Clock clock = Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC); InMemoryNodeRegistry registry = new InMemoryNodeRegistry(clock, Duration.ofMinutes(2)); diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java index 7048a653..7514784a 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -207,6 +207,8 @@ class ControlHttpServerTest { "The shared name field is a selector for vote sites but a dirty editable value for other presets."); assertTrue(script.body().contains("quickSetupDirty = false;\n quickSetupPreserveReadGeneration = -1;\n quickPreset.value = 'vote-site';"), "Opening a detected vote site must clear another preset's dirty guard before automatic READ."); + assertTrue(script.body().contains("if (tabFromHash() === 'configurations') window.setTimeout(() => void autoLoadTab('configurations'), 0);"), + "A clean active YAML editor must automatically reload after apply invalidates its cache."); assertTrue(script.body().contains("function exposeDirtyVoteSiteReload()")); assertTrue(script.body().contains("quickSetupDirty = true;\n exposeDirtyVoteSiteReload();"), "Becoming dirty during the selector debounce must also expose reload."); @@ -341,6 +343,7 @@ class ControlHttpServerTest { + " if (!configurationDirty) {\n" + " configurationContent.value = '';\n configurationContentPresent = false;\n" + " text(fileOperationStatus, 'Configuration changed; read the current file before previewing changes.');\n" + + " if (tabFromHash() === 'configurations') window.setTimeout(() => void autoLoadTab('configurations'), 0);\n" + " }\n lastOverview = null;\n lastDiagnostics = null;\n" + " dashboardConfigurationGeneration++;\n invalidateDashboardInspection();"), "Every successful apply must invalidate file and dashboard reads even after the view context changes.");