From 761c4a0aa7683b80c7dcf78f767c8d792b9f6986 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 14:18:07 -0600 Subject: [PATCH 01/13] Add VoteSites sync: ConfigurationOperations.java --- .../control/domain/ConfigurationOperations.java | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java index 63cf52ed..158bb4f7 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java @@ -27,6 +27,7 @@ public final class ConfigurationOperations implements AutoCloseable { public static final String CAPABILITY = "config.proxy-routing.v1"; public static final String FILE_CAPABILITY = "config.files.v1"; public static final String QUICK_SETUP_CAPABILITY = "config.quick-setup.v1"; + public static final String VOTE_SITES_SYNC_CAPABILITY = "config.vote-sites-sync.v1"; private static final int MAX_OPERATIONS = 1000; private static final int MAX_FILE_OPERATIONS = 16; static final int MAX_RETAINED_CHANGE_BYTES = 256 * 1024; @@ -207,7 +208,7 @@ private StoredOperation store(String type, List targets, ManagedConfigur } private void ensureFileCapacity(ManagedConfiguration config, UUID protectedOperation) { - if (config == null || !ManagedConfiguration.FILE.equals(config.domain())) return; + if (config == null || !largeContentOperation(config)) return; long retained = operations.values().stream().filter(StoredOperation::fileOperation).count(); Iterator> iterator = operations.entrySet().iterator(); while (retained >= MAX_FILE_OPERATIONS && iterator.hasNext()) { @@ -224,6 +225,11 @@ private void ensureFileCapacity(ManagedConfiguration config, UUID protectedOpera } } + private static boolean largeContentOperation(ManagedConfiguration config) { + return ManagedConfiguration.FILE.equals(config.domain()) || + ManagedConfiguration.VOTE_SITES_SYNC.equals(config.preset()); + } + private ConfigurationTaskResult boundedResult(StoredOperation operation, ConfigurationTaskResult result) { ManagedConfiguration configuration = result.success() ? result.configuration() : null; if (configuration != null && ManagedConfiguration.FILE.equals(configuration.domain())) { @@ -404,7 +410,7 @@ private StoredOperation(UUID id, String type, ManagedConfiguration configuration } private boolean complete() { return states.values().stream().allMatch("COMPLETE"::equals); } private boolean fileOperation() { - return configuration != null && ManagedConfiguration.FILE.equals(configuration.domain()); + return configuration != null && largeContentOperation(configuration); } } } From 46b705219b112661a1e266ae6aaad72b3235d84d Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 14:18:09 -0600 Subject: [PATCH 02/13] Add VoteSites sync: InMemoryNodeRegistry.java --- .../votingplugin/control/domain/InMemoryNodeRegistry.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 6537ae8e..fc5c146a 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java @@ -26,7 +26,8 @@ public final class InMemoryNodeRegistry implements NodeRegistry { public static final Set SUPPORTED_CAPABILITIES = Set.of("discovery.read", "presence.snapshot", ConfigurationOperations.CAPABILITY, ConfigurationOperations.FILE_CAPABILITY, - ConfigurationOperations.QUICK_SETUP_CAPABILITY, "config.file-comments.v1"); + ConfigurationOperations.QUICK_SETUP_CAPABILITY, ConfigurationOperations.VOTE_SITES_SYNC_CAPABILITY, + "config.file-comments.v1"); 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}"); private static final Set PLATFORMS = Set.of("BUNGEECORD", "VELOCITY", "BUKKIT"); From 8a5a4072c045cce4247e9030595638a55fd3aac3 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 14:18:10 -0600 Subject: [PATCH 03/13] Add VoteSites sync: ManagedConfiguration.java --- .../protocol/ManagedConfiguration.java | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) 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 e10bf0df..ed985cba 100644 --- a/src/main/java/com/bencodez/votingplugin/control/protocol/ManagedConfiguration.java +++ b/src/main/java/com/bencodez/votingplugin/control/protocol/ManagedConfiguration.java @@ -10,6 +10,7 @@ public record ManagedConfiguration(String domain, Boolean sendVotesToAllServers, public static final String PROXY_ROUTING = "proxy-routing"; public static final String FILE = "file"; public static final String QUICK_SETUP = "quick-setup"; + public static final String VOTE_SITES_SYNC = "sync-vote-sites"; public static final int MAX_CONTENT = 512 * 1024; public ManagedConfiguration { @@ -39,7 +40,7 @@ public record ManagedConfiguration(String domain, Boolean sendVotesToAllServers, } if (options.size() > 20 || options.entrySet().stream().anyMatch(entry -> entry.getKey() == null || !entry.getKey().matches("[a-z][A-Za-z0-9]{0,39}") || entry.getValue() == null - || entry.getValue().length() > 500 || entry.getValue().indexOf('\0') >= 0)) { + || invalidOption(entry.getKey(), entry.getValue(), VOTE_SITES_SYNC.equals(preset)))) { throw new IllegalArgumentException("quick setup options are invalid"); } } @@ -58,14 +59,28 @@ public String capability() { return switch (domain) { case PROXY_ROUTING -> "config.proxy-routing.v1"; case FILE -> "config.files.v1"; - case QUICK_SETUP -> "config.quick-setup.v1"; + case QUICK_SETUP -> VOTE_SITES_SYNC.equals(preset) + ? "config.vote-sites-sync.v1" : "config.quick-setup.v1"; default -> throw new IllegalStateException("unsupported configuration domain"); }; } /** Omits file contents so proposals, including newly entered secrets, are never echoed by operation APIs. */ public ManagedConfiguration publicView() { - return FILE.equals(domain) ? file(fileName, null) : this; + if (FILE.equals(domain)) return file(fileName, null); + if (QUICK_SETUP.equals(domain) && VOTE_SITES_SYNC.equals(preset) && options.containsKey("sourceContent")) { + Map visible = new LinkedHashMap<>(options); + visible.remove("sourceContent"); + return new ManagedConfiguration(domain, sendVotesToAllServers, blockedServers, fileName, content, + preset, visible); + } + return this; + } + + private static boolean invalidOption(String name, String value, boolean voteSitesSync) { + int maximum = voteSitesSync && "sourceContent".equals(name) ? MAX_CONTENT : 500; + return value.indexOf('\0') >= 0 || value.length() > maximum + || value.getBytes(java.nio.charset.StandardCharsets.UTF_8).length > maximum; } private static void validateFileName(String value) { From c3c749dbfd23dfc9e5a22e564939c02cf4a33c47 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 14:18:12 -0600 Subject: [PATCH 04/13] Add VoteSites sync: app.css --- src/main/resources/web/app.css | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/main/resources/web/app.css b/src/main/resources/web/app.css index ab09fc9f..deaeac71 100644 --- a/src/main/resources/web/app.css +++ b/src/main/resources/web/app.css @@ -104,12 +104,19 @@ p { line-height: 1.55; } .backend-state { display: flex; flex-wrap: wrap; gap: 5px; color: var(--muted); font-size: .84rem; } .backend-state span + span::before { margin-right: 5px; content: "·"; color: #53677f; } -.roadmap-card { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0; margin-bottom: 18px; padding: 0; overflow: hidden; } +.roadmap-card { display: grid; grid-template-columns: repeat(2, 1fr); gap: 0; margin-bottom: 18px; padding: 0; overflow: hidden; } .roadmap-card > div { display: grid; grid-template-columns: auto 1fr; gap: 5px 10px; padding: 19px; border-right: 1px solid var(--border); } .roadmap-card > div:last-child { border: 0; } .roadmap-card small { grid-column: 1 / -1; color: var(--muted); } .step { align-self: center; padding: 3px 6px; border: 1px solid #785927; border-radius: 5px; color: var(--amber); font-size: .62rem; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; } #configuration-card { margin-top: 0; } +.sync-card { margin-bottom: 18px; } +.sync-grid { display: grid; grid-template-columns: minmax(240px, .8fr) minmax(300px, 1.2fr); gap: 18px; margin: 20px 0; } +.sync-grid fieldset { margin: 0; } +.target-list { display: grid; max-height: 190px; gap: 7px; overflow: auto; } +.target-list.empty { color: var(--muted); } +.target-option { display: flex; align-items: center; gap: 9px; margin: 0; padding: 8px 9px; border: 1px solid var(--border); border-radius: 8px; color: #d6e5f7; font-size: .85rem; } +.target-option input { flex: none; width: 17px; height: 17px; } .subtabs { display: flex; gap: 7px; padding: 5px; border: 1px solid var(--border); border-radius: 11px; background: #091421; } .subtabs button { flex: 1; border-color: transparent; background: transparent; color: var(--muted); } @@ -149,7 +156,7 @@ select { width: 100%; margin-bottom: 16px; } .check-row { display: flex; align-items: center; gap: 10px; } label span { color: var(--muted); font-weight: 400; } .operation-actions { display: flex; flex-wrap: wrap; gap: 9px; } -#operation-status, #file-operation-status, #quick-operation-status { min-height: 84px; margin: 18px 0 0; padding: 14px; overflow: auto; border: 1px solid var(--border); border-radius: 9px; background: #050c16; color: #c6d5e8; white-space: pre-wrap; } +#operation-status, #file-operation-status, #quick-operation-status, #vote-sites-sync-status { min-height: 84px; margin: 18px 0 0; padding: 14px; overflow: auto; border: 1px solid var(--border); border-radius: 9px; background: #050c16; color: #c6d5e8; white-space: pre-wrap; } #file-operation-status { margin: 0; border: 0; border-top: 1px solid var(--border); border-radius: 0; } .form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 14px; margin: 16px 0; } .form-grid label { color: var(--muted); } @@ -171,6 +178,7 @@ li span { color: var(--muted); font-size: .9rem; } .topbar-actions { flex-wrap: wrap; } .server-picker { order: 3; width: 100%; grid-template-columns: auto 1fr; } .metrics, .settings-grid { grid-template-columns: repeat(2, 1fr); } + .sync-grid { grid-template-columns: 1fr; } .roadmap-card { grid-template-columns: 1fr; } .roadmap-card > div { border-right: 0; border-bottom: 1px solid var(--border); } .topology-row { grid-template-columns: 1fr; } From 9eae0dcda5031782df766cc9c989b010c8599db3 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 14:18:14 -0600 Subject: [PATCH 05/13] Add VoteSites sync: app.js --- src/main/resources/web/app.js | 157 +++++++++++++++++++++++++++++++++- 1 file changed, 156 insertions(+), 1 deletion(-) diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index ed7fb0db..96763646 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -77,6 +77,12 @@ const quickPartyOnline = document.querySelector('#quick-party-online'); const previewQuickSetup = document.querySelector('#preview-quick-setup'); const applyQuickSetup = document.querySelector('#apply-quick-setup'); const quickOperationStatus = document.querySelector('#quick-operation-status'); +const voteSitesSource = document.querySelector('#vote-sites-source'); +const voteSitesTargets = document.querySelector('#vote-sites-targets'); +const voteSitesSyncCapability = document.querySelector('#vote-sites-sync-capability'); +const previewVoteSitesSync = document.querySelector('#preview-vote-sites-sync'); +const applyVoteSitesSync = document.querySelector('#apply-vote-sites-sync'); +const voteSitesSyncStatus = document.querySelector('#vote-sites-sync-status'); const enrollmentCard = document.querySelector('#enrollment-card'); const enrollmentForm = document.querySelector('#enrollment-form'); const enrollmentSubmit = enrollmentForm.querySelector('button[type="submit"]'); @@ -96,6 +102,10 @@ let nodeIndex = new Map(); let approvedPreview = null; let approvedFilePreview = null; let approvedQuickPreview = null; +let approvedVoteSitesPreview = null; +let voteSitesSourceId = ''; +let voteSitesTargetIds = new Set(); +let voteSitesTargetsInitialized = false; let nodeCapabilities = new Map(); let nodePlugins = new Map(); let inputGeneration = 0; @@ -153,7 +163,11 @@ function applyAuthenticatedSession(body) { approvedPreview = null; approvedFilePreview = null; approvedQuickPreview = null; + approvedVoteSitesPreview = null; selectedNodes.clear(); + voteSitesSourceId = ''; + voteSitesTargetIds.clear(); + voteSitesTargetsInitialized = false; configurationContent.value = ''; inputGeneration++; logout.hidden = false; @@ -191,6 +205,7 @@ function friendlyCapability(capability) { return ({ 'config.files.v1': 'Full configuration', 'config.file-comments.v1': 'Comments preserved', + 'config.vote-sites-sync.v1': 'VoteSites sync', 'config.quick-setup.v1': 'Quick Setup', 'config.proxy-routing.v1': 'Proxy routing' })[capability]; @@ -431,6 +446,76 @@ function renderMetrics() { text(metricIssues, visibleNodeItems.filter(node => !node.online).length + unenrolledBackends); } +function syncSourceCandidates() { + return visibleNodeItems.filter(node => isBackend(node) && node.online && + node.acceptedCapabilities.includes('config.files.v1') && + node.acceptedCapabilities.includes('config.file-comments.v1')); +} + +function syncTargetCandidates() { + return visibleNodeItems.filter(node => isBackend(node) && node.online && + node.acceptedCapabilities.includes('config.vote-sites-sync.v1')); +} + +function selectedVoteSitesTargets() { + const capable = new Set(syncTargetCandidates().map(node => node.nodeId)); + return [...voteSitesTargetIds].filter(nodeId => capable.has(nodeId) && nodeId !== voteSitesSourceId); +} + +function renderVoteSitesSync() { + const sources = syncSourceCandidates(); + const targetsAvailable = syncTargetCandidates(); + if (!sources.some(node => node.nodeId === voteSitesSourceId)) { + voteSitesSourceId = sources.find(node => node.nodeId === selectedServerId)?.nodeId || sources[0]?.nodeId || ''; + } + const targetIds = new Set(targetsAvailable.map(node => node.nodeId)); + voteSitesTargetIds = new Set([...voteSitesTargetIds].filter(nodeId => + targetIds.has(nodeId) && nodeId !== voteSitesSourceId)); + if (!voteSitesTargetsInitialized && sources.length > 0) { + voteSitesTargetIds = new Set(targetsAvailable.map(node => node.nodeId) + .filter(nodeId => nodeId !== voteSitesSourceId)); + voteSitesTargetsInitialized = true; + } + + const placeholder = text(document.createElement('option'), sources.length ? 'Choose a source' : 'No readable backends'); + placeholder.value = ''; + voteSitesSource.replaceChildren(placeholder, ...sources.map(node => { + const option = text(document.createElement('option'), `${node.displayName} · ${node.nodeId}`); + option.value = node.nodeId; + return option; + })); + voteSitesSource.value = voteSitesSourceId; + + voteSitesTargets.replaceChildren(); + const choices = targetsAvailable.filter(node => node.nodeId !== voteSitesSourceId); + voteSitesTargets.classList.toggle('empty', choices.length === 0); + if (choices.length === 0) { + text(voteSitesTargets, targetsAvailable.length > 0 + ? 'Choose a different source or enroll another backend.' : 'No sync-capable target backends are connected.'); + } else { + choices.forEach(node => { + const label = document.createElement('label'); + label.className = 'target-option'; + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.checked = voteSitesTargetIds.has(node.nodeId); + checkbox.addEventListener('change', () => { + if (checkbox.checked) voteSitesTargetIds.add(node.nodeId); else voteSitesTargetIds.delete(node.nodeId); + approvedVoteSitesPreview = null; + inputGeneration++; + text(voteSitesSyncStatus, 'Targets changed. Read the source and preview again before syncing.'); + updateConfigurationButtons(); + }); + label.append(checkbox, document.createTextNode(`${node.displayName} · ${node.nodeId}`)); + voteSitesTargets.append(label); + }); + } + const readyCount = targetsAvailable.length; + text(voteSitesSyncCapability, readyCount > 0 + ? `${readyCount} sync-capable ${readyCount === 1 ? 'backend' : 'backends'}` : 'Waiting for capable backends'); + voteSitesSyncCapability.className = `pill ${readyCount > 0 ? 'online' : 'neutral'}`; +} + function renderNodeViews() { nodes.replaceChildren(); nodes.classList.toggle('empty', visibleNodeItems.length === 0); @@ -442,6 +527,7 @@ function renderNodeViews() { renderMetrics(); renderTopology(); renderSelectedServer(); + renderVoteSitesSync(); } function selectPrimaryServer(nodeId) { @@ -459,6 +545,7 @@ function updateConfigurationButtons(busy = configurationOperationsInFlight > 0) const routingReady = authenticated && targets('config.proxy-routing.v1').length > 0 && !busy; const fileReady = authenticated && targets('config.files.v1').length > 0 && !busy; const quickReady = authenticated && targets('config.quick-setup.v1').length > 0 && !busy; + const voteSitesReady = authenticated && voteSitesSourceId && selectedVoteSitesTargets().length > 0 && !busy; readConfiguration.disabled = !routingReady; previewConfiguration.disabled = !routingReady; applyConfiguration.disabled = !routingReady || !approvedPreview; @@ -467,6 +554,8 @@ function updateConfigurationButtons(busy = configurationOperationsInFlight > 0) applyFileConfiguration.disabled = !fileReady || !approvedFilePreview; previewQuickSetup.disabled = !quickReady; applyQuickSetup.disabled = !quickReady || !approvedQuickPreview; + previewVoteSitesSync.disabled = !voteSitesReady; + applyVoteSitesSync.disabled = !voteSitesReady || !approvedVoteSitesPreview; } function targets(capability) { @@ -477,6 +566,8 @@ function clearApprovals() { approvedPreview = null; approvedFilePreview = null; approvedQuickPreview = null; + approvedVoteSitesPreview = null; + approvedVoteSitesPreview = null; inputGeneration++; updateConfigurationButtons(); } @@ -570,6 +661,7 @@ function discardAuthenticationState(reason) { approvedPreview = null; approvedFilePreview = null; approvedQuickPreview = null; + approvedVoteSitesPreview = null; inputGeneration++; logout.hidden = true; appShell.hidden = true; @@ -581,6 +673,9 @@ function discardAuthenticationState(reason) { enrollmentList.replaceChildren(text(document.createElement('li'), 'Authenticate to manage enrollments.')); text(enrollmentMessage, ''); selectedNodes.clear(); + voteSitesSourceId = ''; + voteSitesTargetIds.clear(); + voteSitesTargetsInitialized = false; selectedServerId = ''; visibleNodeItems = []; nodeIndex.clear(); @@ -595,6 +690,7 @@ function discardAuthenticationState(reason) { text(operationStatus, ''); text(fileOperationStatus, ''); text(quickOperationStatus, ''); + text(voteSitesSyncStatus, ''); nodes.replaceChildren(); nodes.classList.add('empty'); text(nodes, 'Authenticate to view the network.'); @@ -732,10 +828,15 @@ async function loadNodes() { nodeCapabilities.get(node)?.includes('config.files.v1')); const invalidQuickApproval = approvedQuickPreview && !approvedQuickPreview.nodeIds.every(node => nodeCapabilities.get(node)?.includes('config.quick-setup.v1')); - if (invalidRoutingApproval || invalidFileApproval || invalidQuickApproval) { + const invalidVoteSitesApproval = approvedVoteSitesPreview && + (!approvedVoteSitesPreview.nodeIds.every(node => + nodeCapabilities.get(node)?.includes('config.vote-sites-sync.v1')) || + !nodeCapabilities.get(approvedVoteSitesPreview.sourceId)?.includes('config.file-comments.v1')); + if (invalidRoutingApproval || invalidFileApproval || invalidQuickApproval || invalidVoteSitesApproval) { if (invalidRoutingApproval) approvedPreview = null; if (invalidFileApproval) approvedFilePreview = null; if (invalidQuickApproval) approvedQuickPreview = null; + if (invalidVoteSitesApproval) approvedVoteSitesPreview = null; inputGeneration++; text(operationStatus, 'A preview target went offline or lost the required capability. Preview again before apply.'); } @@ -1056,6 +1157,60 @@ applyQuickSetup.addEventListener('click', async () => { } catch (error) { text(quickOperationStatus, error.message); } }); +voteSitesSource.addEventListener('change', () => { + voteSitesSourceId = voteSitesSource.value; + voteSitesTargetIds.delete(voteSitesSourceId); + voteSitesTargetsInitialized = true; + approvedVoteSitesPreview = null; + inputGeneration++; + renderVoteSitesSync(); + text(voteSitesSyncStatus, 'Source changed. Read it and preview every target before syncing.'); + updateConfigurationButtons(); +}); + +previewVoteSitesSync.addEventListener('click', async () => { + approvedVoteSitesPreview = null; + const previewGeneration = inputGeneration; + const sourceId = voteSitesSourceId; + const nodeIds = selectedVoteSitesTargets(); + try { + const read = await startConfigurationOperation('/api/v1/configuration/read', { + nodeIds: [sourceId], configuration: {domain: 'file', fileName: 'VoteSites.yml'} + }, voteSitesSyncStatus); + const source = Object.values(read.results).find(result => + result.success && result.configuration?.content != null)?.configuration?.content; + if (source == null) throw new Error('The source backend did not return VoteSites.yml.'); + if (previewGeneration !== inputGeneration || sourceId !== voteSitesSourceId) { + text(voteSitesSyncStatus, 'The source or targets changed while reading. Preview again.'); + return; + } + const preview = await startConfigurationOperation('/api/v1/configuration/preview', { + nodeIds, + configuration: {domain: 'quick-setup', preset: 'sync-vote-sites', options: {sourceContent: source}} + }, voteSitesSyncStatus); + if (preview.state === 'SUCCEEDED' && preview.approvalToken && previewGeneration === inputGeneration) { + approvedVoteSitesPreview = {operationId: preview.operationId, approvalToken: preview.approvalToken, + nodeIds, sourceId}; + updateConfigurationButtons(); + } else if (previewGeneration !== inputGeneration) { + text(voteSitesSyncStatus, 'The source or targets changed while previewing. Preview again before syncing.'); + } + } catch (error) { text(voteSitesSyncStatus, error.message); } +}); + +applyVoteSitesSync.addEventListener('click', async () => { + if (!approvedVoteSitesPreview || !window.confirm( + 'Sync the previewed VoteSites definitions to every selected backend? Target rewards and target-only sites are preserved.')) return; + const approval = approvedVoteSitesPreview; + approvedVoteSitesPreview = null; + inputGeneration++; + try { + await startConfigurationOperation('/api/v1/configuration/apply', { + previewOperationId: approval.operationId, approvalToken: approval.approvalToken + }, voteSitesSyncStatus); + } catch (error) { text(voteSitesSyncStatus, error.message); } +}); + [configurationContent, quickName, quickService, quickUrl, quickDelay, quickRewardScope, quickCommand, quickMessage, quickProcessRewards, quickAutoSites, quickExtraCheck, quickCountFake, quickHideSiteWarning, quickDisableUpdates, quickPartyVotes, quickPartyCommand, quickPartyBroadcast, From a1629d5e1b43bf13e1988b67098d2597d1c07e8e Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 14:18:16 -0600 Subject: [PATCH 06/13] Add VoteSites sync: index.html --- src/main/resources/web/index.html | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/main/resources/web/index.html b/src/main/resources/web/index.html index 7c8ea762..875cb5b0 100644 --- a/src/main/resources/web/index.html +++ b/src/main/resources/web/index.html @@ -141,7 +141,30 @@

Network

NextProxy methodOne-click selection with required-setting checks
NextCommunication testNon-vote connectivity checks without rewards
-
NextSync VoteSitesCopy site definitions while preserving rewards
+
+
+
+
+

Network action

+

Sync VoteSites

+

Copy site definitions from one backend. Target-only sites and every reward-named section are preserved.

+
+ Waiting for capable backends +
+
+ +
+ Target backends +
No sync-capable backends loaded.
+
+
+
+ + +
+
Choose a source and at least one target. Rewards are never copied or removed.
From 22603b2a58fcd1398230d9ce543ebfcbca20485e Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 14:18:18 -0600 Subject: [PATCH 07/13] Add VoteSites sync: ConfigurationOperationsTest.java --- .../domain/ConfigurationOperationsTest.java | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) 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 a97a2d39..256012a6 100644 --- a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java @@ -447,6 +447,30 @@ class ConfigurationOperationsTest { ManagedConfiguration.FILE, null, List.of("other"), "Config.yml", null, null, Map.of())); } + @Test void voteSitesSyncAllowsOneBoundedSourceDocumentAndHidesItFromOperationViews() throws Exception { + String source = "VoteSites:\n" + "# owner comment\n".repeat(100); + ManagedConfiguration sync = new ManagedConfiguration(ManagedConfiguration.QUICK_SETUP, null, List.of(), + null, null, ManagedConfiguration.VOTE_SITES_SYNC, Map.of("sourceContent", source)); + + assertEquals(ConfigurationOperations.VOTE_SITES_SYNC_CAPABILITY, sync.capability()); + assertFalse(sync.publicView().options().containsKey("sourceContent")); + assertThrows(IllegalArgumentException.class, () -> new ManagedConfiguration(ManagedConfiguration.QUICK_SETUP, + null, List.of(), null, null, ManagedConfiguration.VOTE_SITES_SYNC, + Map.of("sourceContent", "x".repeat(ManagedConfiguration.MAX_CONTENT + 1)))); + + 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("backend-a", session, "Backend A", "BUKKIT", "test", 1, + Set.of(ConfigurationOperations.VOTE_SITES_SYNC_CAPABILITY), Set.of())); + ConfigurationOperations operations = new ConfigurationOperations(registry, + new ConfigurationAuditLog(directory, clock), clock); + + ConfigurationOperations.OperationView preview = operations.createPreview(List.of("backend-a"), sync); + assertFalse(preview.configuration().options().containsKey("sourceContent")); + assertEquals(source, operations.claim("backend-a", session).configuration().options().get("sourceContent")); + } + @Test void fileAndQuickSetupOperationsRequireTheirNegotiatedCapabilities() throws Exception { Clock clock = Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC); InMemoryNodeRegistry registry = new InMemoryNodeRegistry(clock, Duration.ofMinutes(2)); From 909afcd6389110b460adc5dbc9e9d1d401155385 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 14:18:19 -0600 Subject: [PATCH 08/13] Add VoteSites sync: InMemoryNodeRegistryTest.java --- .../votingplugin/control/domain/InMemoryNodeRegistryTest.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 8ce5f94c..73943837 100644 --- a/src/test/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistryTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistryTest.java @@ -36,7 +36,8 @@ class InMemoryNodeRegistryTest { } @Test void registrationNegotiatesOptionalCommentPreservingFileSupport() { - Set capabilities = Set.of("config.files.v1", "config.file-comments.v1"); + Set capabilities = Set.of("config.files.v1", "config.file-comments.v1", + ConfigurationOperations.VOTE_SITES_SYNC_CAPABILITY); var result = registry.register(registration("backend-lobby", session, capabilities, Set.of("config.files.v1"))); From 3724ddb713d79e8572d9e26c15544bb7f230c105 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 14:18:22 -0600 Subject: [PATCH 09/13] Add VoteSites sync: ControlHttpServerTest.java --- .../votingplugin/control/http/ControlHttpServerTest.java | 5 +++++ 1 file changed, 5 insertions(+) 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 b3acce6c..cb2b0429 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -65,6 +65,8 @@ class ControlHttpServerTest { assertTrue(web.body().contains("id=\"server-picker\"")); assertTrue(web.body().contains("Full YAML")); assertTrue(web.body().contains("Comment support unknown")); + assertTrue(web.body().contains("Sync VoteSites")); + assertTrue(web.body().contains("Rewards are never copied or removed")); assertTrue(web.headers().firstValue("Content-Security-Policy").orElseThrow().contains("default-src 'self'")); HttpResponse script = get("/app.js", null); assertEquals(200, script.statusCode()); @@ -103,6 +105,9 @@ class ControlHttpServerTest { assertTrue(script.body().contains("No connected proxy reports this backend ID")); assertTrue(script.body().contains("'config.files.v1': 'Full configuration'")); assertTrue(script.body().contains("'config.file-comments.v1': 'Comments preserved'")); + assertTrue(script.body().contains("'config.vote-sites-sync.v1': 'VoteSites sync'")); + assertTrue(script.body().contains("preset: 'sync-vote-sites'")); + assertTrue(script.body().contains("sourceContent: source")); assertTrue(script.body().contains("handleEditorKeydown")); assertFalse(script.body().contains("'No backends reported.'")); assertFalse(script.body().contains("'No Bukkit plugin inventory reported.'")); From 9142c8bbf68a2a01ab8192703ac91ba063160a4b Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 14:41:33 -0600 Subject: [PATCH 10/13] Fix configuration operation test imports --- .../votingplugin/control/domain/ConfigurationOperationsTest.java | 1 + 1 file changed, 1 insertion(+) 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 256012a6..76482ac3 100644 --- a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java @@ -1,6 +1,7 @@ package com.bencodez.votingplugin.control.domain; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; From 9bbee6857c5a31f2340f579be43671c1543fbcfa Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 15:16:37 -0600 Subject: [PATCH 11/13] Address Codex VoteSites sync review findings --- .../domain/ConfigurationOperations.java | 4 +- src/main/resources/web/app.js | 65 +++++++++++++------ .../domain/ConfigurationOperationsTest.java | 8 ++- .../control/http/ControlHttpServerTest.java | 4 ++ 4 files changed, 58 insertions(+), 23 deletions(-) diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java index 158bb4f7..d317b7b9 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java @@ -232,7 +232,9 @@ private static boolean largeContentOperation(ManagedConfiguration config) { private ConfigurationTaskResult boundedResult(StoredOperation operation, ConfigurationTaskResult result) { ManagedConfiguration configuration = result.success() ? result.configuration() : null; - if (configuration != null && ManagedConfiguration.FILE.equals(configuration.domain())) { + if (configuration != null && ManagedConfiguration.VOTE_SITES_SYNC.equals(configuration.preset())) { + configuration = configuration.publicView(); + } else if (configuration != null && ManagedConfiguration.FILE.equals(configuration.domain())) { boolean keepContent = result.success() && "READ".equals(operation.type) && operation.results.values().stream().filter(ConfigurationTaskResult::success) .map(ConfigurationTaskResult::configuration).filter(Objects::nonNull) diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index 96763646..136b331c 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -98,7 +98,9 @@ let pageOffset = 0; let selectedNodes = new Set(); let selectedServerId = ''; let visibleNodeItems = []; +let allNodeItems = []; let nodeIndex = new Map(); +let enrollmentIds = new Set(); let approvedPreview = null; let approvedFilePreview = null; let approvedQuickPreview = null; @@ -216,7 +218,7 @@ function managedCapabilities(node) { } function proxyReportsFor(backendId) { - return visibleNodeItems.filter(isProxy).filter(proxy => + return allNodeItems.filter(isProxy).filter(proxy => (Array.isArray(proxy.backends) ? proxy.backends : []).some(backend => backend.backendId === backendId)); } @@ -226,8 +228,8 @@ function backendCard(backend) { const details = document.createElement('div'); details.className = 'backend-state'; const registered = nodeIndex.get(backend.backendId); - details.append(text(document.createElement('span'), registered - ? `Enrolled in Control · ${registered.online ? 'Control connected' : 'Control disconnected'}` + details.append(text(document.createElement('span'), enrollmentIds.has(backend.backendId) + ? `Enrolled in Control · ${registered ? (registered.online ? 'Control connected' : 'Control disconnected') : 'not registered'}` : 'Not enrolled in Control')); if (backend.presenceKnown) { details.append(text(document.createElement('span'), backend.available @@ -338,7 +340,7 @@ function chooseDefaultServer(items) { function renderServerPicker() { const previousValue = selectedServerId; - const ordered = [...visibleNodeItems].sort((left, right) => { + const ordered = [...allNodeItems].sort((left, right) => { const roleOrder = Number(isProxy(left)) - Number(isProxy(right)); return roleOrder || left.displayName.localeCompare(right.displayName); }); @@ -394,8 +396,8 @@ function topologyLink(backend) { const link = document.createElement('span'); link.className = `topology-link ${registered?.online ? 'online' : 'warning'}`; link.append(text(document.createElement('strong'), backend.displayName)); - link.append(text(document.createElement('small'), registered - ? `Enrolled · Control ${registered.online ? 'connected' : 'disconnected'}` + link.append(text(document.createElement('small'), enrollmentIds.has(backend.backendId) + ? `Enrolled · Control ${registered ? (registered.online ? 'connected' : 'disconnected') : 'not registered'}` : 'Not enrolled in Control')); if (backend.presenceKnown) { link.append(text(document.createElement('small'), backend.available @@ -408,7 +410,7 @@ function topologyLink(backend) { } function renderTopology() { - const proxies = visibleNodeItems.filter(isProxy); + const proxies = allNodeItems.filter(isProxy); topology.replaceChildren(); topology.classList.toggle('empty', proxies.length === 0); if (proxies.length === 0) { @@ -437,23 +439,23 @@ function renderTopology() { } function renderMetrics() { - const backendIds = new Set(visibleNodeItems.filter(isProxy).flatMap(node => + const backendIds = new Set(allNodeItems.filter(isProxy).flatMap(node => (Array.isArray(node.backends) ? node.backends : []).map(backend => backend.backendId))); - const unenrolledBackends = [...backendIds].filter(backendId => !nodeIndex.has(backendId)).length; - text(metricNodes, visibleNodeItems.length); - text(metricOnline, visibleNodeItems.filter(node => node.online).length); + const unenrolledBackends = [...backendIds].filter(backendId => !enrollmentIds.has(backendId)).length; + text(metricNodes, allNodeItems.length); + text(metricOnline, allNodeItems.filter(node => node.online).length); text(metricBackends, backendIds.size); - text(metricIssues, visibleNodeItems.filter(node => !node.online).length + unenrolledBackends); + text(metricIssues, allNodeItems.filter(node => !node.online).length + unenrolledBackends); } function syncSourceCandidates() { - return visibleNodeItems.filter(node => isBackend(node) && node.online && + return allNodeItems.filter(node => isBackend(node) && node.online && node.acceptedCapabilities.includes('config.files.v1') && node.acceptedCapabilities.includes('config.file-comments.v1')); } function syncTargetCandidates() { - return visibleNodeItems.filter(node => isBackend(node) && node.online && + return allNodeItems.filter(node => isBackend(node) && node.online && node.acceptedCapabilities.includes('config.vote-sites-sync.v1')); } @@ -469,8 +471,14 @@ function renderVoteSitesSync() { voteSitesSourceId = sources.find(node => node.nodeId === selectedServerId)?.nodeId || sources[0]?.nodeId || ''; } const targetIds = new Set(targetsAvailable.map(node => node.nodeId)); - voteSitesTargetIds = new Set([...voteSitesTargetIds].filter(nodeId => + const retainedTargets = new Set([...voteSitesTargetIds].filter(nodeId => targetIds.has(nodeId) && nodeId !== voteSitesSourceId)); + if (retainedTargets.size !== voteSitesTargetIds.size) { + approvedVoteSitesPreview = null; + inputGeneration++; + text(voteSitesSyncStatus, 'A sync target became unavailable. Preview again before syncing.'); + } + voteSitesTargetIds = retainedTargets; if (!voteSitesTargetsInitialized && sources.length > 0) { voteSitesTargetIds = new Set(targetsAvailable.map(node => node.nodeId) .filter(nodeId => nodeId !== voteSitesSourceId)); @@ -678,6 +686,8 @@ function discardAuthenticationState(reason) { voteSitesTargetsInitialized = false; selectedServerId = ''; visibleNodeItems = []; + allNodeItems = []; + enrollmentIds.clear(); nodeIndex.clear(); nodeCapabilities.clear(); nodePlugins.clear(); @@ -754,6 +764,8 @@ async function loadEnrollments() { refreshEnrollments.disabled = true; try { const body = await authorized('/api/v1/enrollments'); + enrollmentIds = new Set(Array.isArray(body.nodeIds) ? body.nodeIds : []); + if (allNodeItems.length) renderNodeViews(); enrollmentList.replaceChildren(); if (!Array.isArray(body.nodeIds) || body.nodeIds.length === 0) { enrollmentList.append(text(document.createElement('li'), 'No nodes are enrolled yet.')); @@ -796,6 +808,15 @@ async function loadEnrollments() { } } +async function loadAllNodes() { + const items = []; + for (let offset = 0; ; offset += PAGE_SIZE) { + const page = await authorized(`/api/v1/nodes?offset=${offset}&limit=${PAGE_SIZE}`); + items.push(...page.items); + if (page.items.length < PAGE_SIZE) return items; + } +} + async function loadNodes() { if (!authenticated) return; refresh.disabled = true; @@ -803,13 +824,17 @@ async function loadNodes() { nextPage.disabled = true; text(message, 'Loading…'); try { - const body = await authorized(`/api/v1/nodes?offset=${pageOffset}&limit=${PAGE_SIZE}`); + const [body, registry] = await Promise.all([ + authorized(`/api/v1/nodes?offset=${pageOffset}&limit=${PAGE_SIZE}`), + loadAllNodes() + ]); visibleNodeItems = body.items; - nodeIndex = new Map(body.items.map(node => [node.nodeId, node])); + allNodeItems = registry; + nodeIndex = new Map(registry.map(node => [node.nodeId, node])); const needsDefaultTargets = !selectedServerId || !nodeIndex.has(selectedServerId); const previousCapabilities = nodeCapabilities; - nodeCapabilities = new Map(body.items.map(node => [node.nodeId, node.online ? node.acceptedCapabilities : []])); - nodePlugins = new Map(body.items.map(node => [node.nodeId, node.online && Array.isArray(node.detectedPlugins) + nodeCapabilities = new Map(registry.map(node => [node.nodeId, node.online ? node.acceptedCapabilities : []])); + nodePlugins = new Map(registry.map(node => [node.nodeId, node.online && Array.isArray(node.detectedPlugins) ? node.detectedPlugins : []])); const selectedCapabilitiesChanged = [...selectedNodes].some(node => ['config.proxy-routing.v1', 'config.files.v1', 'config.quick-setup.v1'].some(capability => @@ -840,7 +865,7 @@ async function loadNodes() { inputGeneration++; text(operationStatus, 'A preview target went offline or lost the required capability. Preview again before apply.'); } - const visibleIds = new Set(body.items.filter(node => node.online && node.acceptedCapabilities.some(value => value.startsWith('config.'))) + const visibleIds = new Set(registry.filter(node => node.online && node.acceptedCapabilities.some(value => value.startsWith('config.'))) .map(node => node.nodeId)); const filteredSelection = new Set([...selectedNodes].filter(node => visibleIds.has(node))); if (filteredSelection.size !== selectedNodes.size) { 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 76482ac3..f6ea1463 100644 --- a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java @@ -1,7 +1,6 @@ package com.bencodez.votingplugin.control.domain; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -469,7 +468,12 @@ class ConfigurationOperationsTest { ConfigurationOperations.OperationView preview = operations.createPreview(List.of("backend-a"), sync); assertFalse(preview.configuration().options().containsKey("sourceContent")); - assertEquals(source, operations.claim("backend-a", session).configuration().options().get("sourceContent")); + ConfigurationTask syncTask = operations.claim("backend-a", session); + assertEquals(source, syncTask.configuration().options().get("sourceContent")); + ConfigurationOperations.OperationView completed = operations.complete(preview.operationId(), "backend-a", + new ConfigurationTaskResult(session, true, "OK", "valid", "e".repeat(64), sync, + List.of("changed VoteSites.Example"), false, false, syncTask.attemptId())); + assertFalse(completed.results().get("backend-a").configuration().options().containsKey("sourceContent")); } @Test void fileAndQuickSetupOperationsRequireTheirNegotiatedCapabilities() throws Exception { diff --git a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java index cb2b0429..7629f229 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -71,6 +71,8 @@ class ControlHttpServerTest { HttpResponse script = get("/app.js", null); assertEquals(200, script.statusCode()); assertTrue(script.body().contains("offset=${pageOffset}&limit=${PAGE_SIZE}")); + assertTrue(script.body().contains("async function loadAllNodes()")); + assertTrue(script.body().contains("enrollmentIds.has(backend.backendId)")); assertTrue(script.body().contains("nextPage.addEventListener")); assertTrue(script.body().contains("result.configuration?.content != null")); assertTrue(script.body().contains("authenticationGeneration")); @@ -107,6 +109,8 @@ class ControlHttpServerTest { assertTrue(script.body().contains("'config.file-comments.v1': 'Comments preserved'")); assertTrue(script.body().contains("'config.vote-sites-sync.v1': 'VoteSites sync'")); assertTrue(script.body().contains("preset: 'sync-vote-sites'")); + assertTrue(script.body().contains("return allNodeItems.filter(node => isBackend(node)")); + assertTrue(script.body().contains("A sync target became unavailable")); assertTrue(script.body().contains("sourceContent: source")); assertTrue(script.body().contains("handleEditorKeydown")); assertFalse(script.body().contains("'No backends reported.'")); From 368c9c774b818b796f61adec298382648646a6ee Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 15:17:26 -0600 Subject: [PATCH 12/13] Align VoteSites stack with workspace fixes --- src/main/resources/web/index.html | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/resources/web/index.html b/src/main/resources/web/index.html index 875cb5b0..45f8e295 100644 --- a/src/main/resources/web/index.html +++ b/src/main/resources/web/index.html @@ -63,9 +63,10 @@

Sign in to your network

-

+

+