diff --git a/AGENTS.md b/AGENTS.md index be530aa6..79e6d4c2 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, 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 +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/README.md b/README.md index 945f11c7..67cd4435 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, vote-party, auto-create-vote-sites, vote-logging, and typed reward-builder presets. +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/docs/control-management.md b/docs/control-management.md index 93e9abca..755c3a88 100644 --- a/docs/control-management.md +++ b/docs/control-management.md @@ -42,12 +42,16 @@ 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 | +| `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 @@ -126,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/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..ee3e0be8 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java @@ -30,9 +30,11 @@ 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"; + 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; @@ -260,13 +262,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()) @@ -318,8 +330,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; } @@ -471,6 +491,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() @@ -673,10 +704,17 @@ 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())) + || (!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/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java b/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java index 176c010f..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,8 +28,11 @@ 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); 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..92fee5a8 100644 --- a/src/main/java/com/bencodez/votingplugin/control/protocol/ManagedConfiguration.java +++ b/src/main/java/com/bencodez/votingplugin/control/protocol/ManagedConfiguration.java @@ -112,10 +112,15 @@ 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"); } + 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"); @@ -129,7 +134,11 @@ 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" + : "proxy-backend".equals(preset) && "HTTP".equals(options.get("method")) + ? "config.proxy-method.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 1b2500f9..e5f071ad 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'); @@ -252,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; @@ -261,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; @@ -274,6 +279,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 +362,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.'} ]); @@ -630,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); @@ -642,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); @@ -1076,7 +1085,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(); + invalidateGuidedSetupReads(); + 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)) { @@ -1169,7 +1182,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, @@ -1237,6 +1250,7 @@ function applyAuthenticatedSession(body) { approvedFilePreview = null; approvedQuickPreview = null; loadedQuickSetup = null; + quickSetupDirty = false; selectedNodes.clear(); voteSitesSourceId = ''; voteSitesTargetIds.clear(); @@ -1321,7 +1335,9 @@ 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.quick-setup.v2': 'Vote Party setup', 'config.proxy-routing.v1': 'Proxy routing', 'data.inspect.v1': 'Read-only data inspection' })[capability]; @@ -1399,12 +1415,14 @@ 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.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); 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.`); @@ -1416,6 +1434,7 @@ function nodeCard(node) { approvedQuickPreview = null; dedicatedSetupApprovals.clear(); inputGeneration++; + handleQuickTargetCapabilityChange(previousQuickCapability); updatePluginSuggestions(); renderSelectedServer(); updateConfigurationButtons(); @@ -1478,7 +1497,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; } @@ -1497,9 +1517,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(); } @@ -1528,6 +1551,9 @@ function resetFileEditorForSelection(message) { lastFileReadOperation = null; approvedFilePreview = null; updateEditorPosition(); + configurationContent.disabled = false; + configurationContent.removeAttribute('aria-busy'); + readFileConfiguration.hidden = true; text(fileOperationStatus, message); } @@ -1599,8 +1625,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 +1638,39 @@ async function autoLoadTab(tab) { } finally { finishAutoLoad(tab); } return; } - if (tab === 'quick-setup' && quickPresetReadable() && !loadedQuickSetup - && !approvedQuickPreview && !configurationOperationsInFlight) { + if (tab === 'quick-setup' && configurationOperationsInFlight) { + autoLoadPending.add(tab); + return; + } + if (tab === 'quick-setup' && !approvedQuickPreview + && (!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 { await loadQuickSetupValues(true); } finally { finishAutoLoad(tab); } + try { + const autoLoadGeneration = inputGeneration; + 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 (!dedicatedSetupDirty.has('vote-logging') && voteLoggingState.textContent === 'Not loaded') { + await loadDedicatedSetup('vote-logging', true); + } + if (inputGeneration !== autoLoadGeneration) { + autoLoadPending.add(tab); + return; + } + if (quickPresetReadable() && (!quickSetupDirty || quickSetupPreserveReadGeneration === inputGeneration) + && !quickSetupValuesLoaded()) { + const preserveGeneration = quickSetupPreserveReadGeneration; + await loadQuickSetupValues(true, quickSetupDirty); + if (quickSetupPreserveReadGeneration === preserveGeneration) quickSetupPreserveReadGeneration = -1; + } + } finally { finishAutoLoad(tab); } return; } if (tab === 'data' && inspectionCapableNode() && !inspectionInFlight && !lastOverview) { @@ -1821,7 +1875,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}; } @@ -2530,25 +2584,40 @@ 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 proxyMethodNetworkFor(items, truncatedNodeIds, proxyId) { +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); - 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) { @@ -2569,7 +2638,7 @@ function renderProxyMethod() { return option; })); proxyMethodProxy.value = proxyMethodProxyId; - const network = proxyMethodNetwork(); + const network = proxyMethodReadNetwork(); if (proxyMethodCurrentFor !== proxyMethodProxyId || proxyMethodCurrentSessionId !== (network.proxy?.sessionId || '')) { proxyMethodCurrentFor = ''; @@ -2590,8 +2659,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; }); } @@ -2650,13 +2722,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; @@ -2730,6 +2802,7 @@ function resetServerConfigurationForms(status, preserveDirtyDrafts = false) { } function resetDedicatedSetupValues() { + dedicatedSetupDirty.clear(); autoSitesEnabled.checked = false; voteLoggingEnabled.checked = false; voteLoggingDays.value = '30'; @@ -2738,6 +2811,25 @@ function resetDedicatedSetupValues() { text(voteLoggingState, 'Not loaded'); autoSitesState.className = 'pill neutral'; voteLoggingState.className = 'pill neutral'; + loadAutoSites.hidden = true; + 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) { @@ -2777,6 +2869,8 @@ 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; quickSetupForm.reset(); @@ -2816,9 +2910,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; @@ -2829,10 +2924,12 @@ function updateConfigurationButtons(busy = configurationOperationsInFlight > 0 | previewQuickSetup.disabled = !quickReady || (quickPresetNeedsRead() && !quickSetupValuesLoaded()); 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; }); + const methodNetwork = proxyMethodReadNetwork(); + 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; } @@ -2844,6 +2941,52 @@ function backendQuickTargets() { return targets('config.quick-setup.v1').filter(nodeId => nodeIndex.has(nodeId) && isBackend(nodeIndex.get(nodeId))); } +function quickSetupCapability() { + 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() { + const selectedBackends = [...selectedNodes].filter(nodeId => nodeIndex.has(nodeId) + && 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) { + 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))); +} + function clearApprovals() { approvedPreview = null; approvedFilePreview = null; @@ -2889,7 +3032,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(); @@ -2909,7 +3052,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() { @@ -2969,6 +3112,7 @@ function discardAuthenticationState(reason) { approvedFilePreview = null; approvedQuickPreview = null; loadedQuickSetup = null; + quickSetupDirty = false; inputGeneration++; logout.hidden = true; sidebarToggle.hidden = true; @@ -3111,6 +3255,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 +3292,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'); }); @@ -3176,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; @@ -3202,6 +3361,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); @@ -3231,6 +3392,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'); + } } } @@ -3355,6 +3521,7 @@ async function loadNodesOnce() { try { const registry = await loadAllNodes(); const previousNodeIndex = nodeIndex; + const previousQuickCapability = quickSetupCapability(); allNodeItems = registry.items; nodePageMetadata = registry.pageMetadata; selectNodePage(pageOffset); @@ -3373,12 +3540,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.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; @@ -3400,7 +3580,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')) || @@ -3436,6 +3616,7 @@ async function loadNodesOnce() { text(operationStatus, routingDraftStatus('The selected nodes changed during refresh. Preview again before apply.')); } selectedNodes = filteredSelection; + handleQuickTargetCapabilityChange(previousQuickCapability); renderNodeViews(); updatePluginSuggestions(); updateConfigurationButtons(); @@ -3672,6 +3853,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 +3877,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 +3895,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 +3927,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 +3996,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))}); + 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 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 = 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); + 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,15 +4047,26 @@ function quickOptions() { disableNoServiceSiteMessage: String(quickHideSiteWarning.checked), disableUpdateChecking: String(quickDisableUpdates.checked) }; - return {votesRequired: quickPartyVotes.value, command: quickPartyCommand.value.trim(), + 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() { return quickPreset.value === 'vote-site' ? {name: quickName.value.trim()} : {}; } +function quickReadConfigurationOptions() { + if (quickPreset.value === 'proxy-backend') return {method: quickMethod.value}; + if (quickPreset.value === 'vote-party' && quickSetupCapability() === 'config.quick-setup.v2') { + return {enabled: 'true'}; + } + return quickReadOptions(); +} + function populateQuickState(options) { if (quickPreset.value === 'proxy-backend') { quickName.value = options.server || ''; @@ -3869,6 +4094,10 @@ function populateQuickState(options) { quickVoteLoggingDays.value = options.purgeDays || '30'; quickVoteLoggingMainMysql.checked = options.useMainMySQL !== 'false'; } else if (quickPreset.value === 'vote-party') { + 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'; @@ -3877,38 +4106,50 @@ function populateQuickState(options) { } } -async function loadQuickSetupValues(automatic = false) { - if (!quickPresetReadable()) return; +async function loadQuickSetupValues(automatic = false, preserveDirty = false) { + if (!quickPresetReadable()) return false; approvedQuickPreview = null; loadedQuickSetup = null; 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}…`); 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); 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())) { - text(quickOperationStatus, 'The server or setup changed while reading. Load the current values again.'); - return; + || selector !== JSON.stringify(quickReadConfigurationOptions())) { + if (!quickSetupValuesLoaded()) { + text(quickOperationStatus, 'The server or setup changed while reading. Load the current values again.'); + readQuickSetup.hidden = false; + updateConfigurationButtons(); + } + return false; } const detected = preset === 'vote-site' && pendingDetectedVoteSite?.nodeId === nodeId && pendingDetectedVoteSite.key === quickName.value.trim() ? pendingDetectedVoteSite : 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; + if (editedProxyServer != null) quickName.value = editedProxyServer; + quickSetupDirty = preserveDirty; if (detected && result.configuration.options.exists === 'false') { quickSiteDisplayName.value = detected.service; 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.' : ''}` @@ -3917,9 +4158,19 @@ 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(); + return true; } 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(quickReadConfigurationOptions())) { + loadedQuickSetup = null; + text(quickOperationStatus, `Could not load current ${preset} settings: ${error.message}`); + readQuickSetup.hidden = false; + updateConfigurationButtons(); + } + return false; } } @@ -3954,14 +4205,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.'); @@ -3985,12 +4237,16 @@ 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 ? `${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); } }); @@ -4043,19 +4299,23 @@ runTransportTest.addEventListener('click', async () => { async function loadProxyMethod(automatic = false) { const proxyId = proxyMethodProxyId; - const sessionId = proxyMethodNetwork().proxy?.sessionId; + const readCapability = proxyMethodReadCapability(); + const readGeneration = proxyMethodReadGeneration; + 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; + || readGeneration !== proxyMethodReadGeneration || readCapability !== proxyMethodReadCapability() + || sessionId !== proxyMethodNetwork(readCapability).proxy?.sessionId || result?.sessionId !== sessionId) return; proxyMethodCurrentFor = proxyId; proxyMethodCurrentSessionId = sessionId; proxyMethodCurrentValue = method; @@ -4074,7 +4334,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.'); @@ -4083,7 +4343,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 +4358,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 +4403,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: {}} @@ -4172,8 +4434,17 @@ async function loadDedicatedSetup(preset) { 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'}`; - } catch (error) { text(elements.status, error.message); } + text(elements.status, operationSummary(operation)); + } catch (error) { + 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(); } @@ -4224,6 +4495,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.`); @@ -4236,6 +4508,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 @@ -4247,6 +4520,7 @@ selectAllAutoSitesTargets.addEventListener('click', () => { approvedFilePreview = null; approvedQuickPreview = null; inputGeneration++; + handleQuickTargetCapabilityChange(previousQuickCapability); renderNodeViews(); updatePluginSuggestions(); updateConfigurationButtons(); @@ -4258,7 +4532,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(); }); @@ -4790,10 +5066,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); @@ -4805,7 +5079,9 @@ 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); + 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); quickAutoSitesOnly.checked = Boolean(profile.autoSitesOnly); quickVoteLoggingEnabled.checked = Boolean(profile.voteLogging); @@ -4819,10 +5095,36 @@ loadProfile.addEventListener('click', () => { rewardSiteLabel.hidden = rewardScope.value !== 'site'; copyRewardToSetup.disabled = boundedLines(rewardCommands.value).length === 0; } +} + +loadProfile.addEventListener('click', async () => { + 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); + 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 “${profileName}”…`); + if (quickPresetReadable() && !await loadQuickSetupValues(true, true)) { + 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); + quickSetupDirty = true; + inputGeneration++; + updateQuickFields(); + clearApprovals(); + text(profileStatus, `Loaded “${profileName}” over the confirmed live values. Preview before applying.`); }); deleteProfile.addEventListener('click', () => { const name = profilePicker.value; @@ -4838,16 +5140,46 @@ 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, quickPartyVotes, quickPartyCommand, quickPartyBroadcast, + 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; + exposeDirtyVoteSiteReload(); + 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()) { + quickSetupDirty = true; + if (quickSetupDirty) quickSetupPreserveReadGeneration = inputGeneration; + void autoLoadTab('quick-setup'); + } +}); 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(() => { + voteSiteReadTimer = null; + void autoLoadTab('quick-setup'); + }, 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; @@ -4877,6 +5209,7 @@ configurationFile.addEventListener('input', () => { }); quickPreset.addEventListener('input', () => { loadedQuickSetup = null; + quickSetupDirty = false; if (quickPreset.value !== 'vote-site') pendingDetectedVoteSite = null; updateQuickFields(); clearApprovals(); @@ -4885,6 +5218,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..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,6 +116,65 @@ class ConfigurationOperationsTest { assertEquals("false", read.results().get("lobby").configuration().options().get("processRewards")); } + @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(); + 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, "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, "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("HTTP", read.results().get("lobby").configuration().options().get("method")); + } + + @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 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)); @@ -823,6 +882,17 @@ 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()); + 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 { Clock clock = Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC); InMemoryNodeRegistry registry = new InMemoryNodeRegistry(clock, Duration.ofMinutes(2)); @@ -875,6 +945,27 @@ 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()); + + 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)); + 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); } @Test void proxyMethodApplyKeepsEvictedBackendsAsFailedDependencies() throws Exception { @@ -892,6 +983,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/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 fc9141c0..7514784a 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()); @@ -114,10 +116,109 @@ 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;")); + 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("if (!quickSetupValuesLoaded()) {\n text(quickOperationStatus, 'The server or setup changed while reading.")); + 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("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."); + 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("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));")); + 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}"), + "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);")); + 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("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."); + assertTrue(script.body().contains("quickMethod.addEventListener('input', () => {\n if (quickPreset.value === 'proxy-backend' && quickPresetReadable()) {")); + 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 = 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;")); + 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 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.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."); + 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("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."); + 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."); + 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")); @@ -196,6 +297,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() && (!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")); assertTrue(script.body().contains("sessionId !== nodeIndex.get(nodeId)?.sessionId")); @@ -238,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."); @@ -249,21 +355,22 @@ 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."); - 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 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."), "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();")); 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));")); @@ -289,10 +396,9 @@ 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")); + 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")); @@ -334,7 +440,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 +631,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'")); @@ -658,9 +764,25 @@ 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."); + 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."); + 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);