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..ec3f46cd 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; @@ -64,6 +65,7 @@ public synchronized OperationView createRead(List nodeIds, ManagedConfig public synchronized OperationView createPreview(List nodeIds, ManagedConfiguration configuration) { if (configuration == null) throw invalid("configuration is required"); + configuration.validateProposal(); byte[] token = new byte[32]; random.nextBytes(token); return create("PREVIEW", validateTargets(nodeIds, configuration.capability()), configuration, @@ -207,7 +209,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,9 +226,16 @@ 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())) { + 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) @@ -404,7 +413,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); } } } 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 7d090de0..78f7002d 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"); 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..056fd4ea 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"); } } @@ -54,18 +55,39 @@ public static ManagedConfiguration file(String fileName, String content) { return new ManagedConfiguration(FILE, null, List.of(), fileName, content, null, Map.of()); } + public void validateProposal() { + if (QUICK_SETUP.equals(domain) && VOTE_SITES_SYNC.equals(preset) + && (options.size() != 1 || !options.containsKey("sourceContent"))) { + throw new IllegalArgumentException("VoteSites sync requires sourceContent"); + } + } + 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) { + boolean sourceContent = voteSitesSync && "sourceContent".equals(name); + int maximum = sourceContent ? MAX_CONTENT : 500; + return value.indexOf('\0') >= 0 || value.length() > maximum; } private static void validateFileName(String value) { 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; } diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index d132bcdf..4d2cb49f 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"]'); @@ -87,6 +93,7 @@ const enrollmentMessage = document.querySelector('#enrollment-message'); const refreshEnrollments = document.querySelector('#refresh-enrollments'); const PAGE_SIZE = 100; const MAX_CONFIGURATION_TARGETS = 100; +const MAX_SYNC_TARGETS = 100; const MAX_REGISTRY_SCAN_ATTEMPTS = 3; let authenticated = false; let csrfToken = ''; @@ -102,6 +109,10 @@ let backendTopologyTruncated = false; 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; @@ -159,7 +170,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; @@ -197,6 +212,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]; @@ -471,6 +487,93 @@ function renderMetrics() { text(metricIssues, backendTopologyTruncated ? `${issueCount}+` : issueCount); } +function syncSourceCandidates() { + return allNodeItems.filter(node => isBackend(node) && node.online && + node.acceptedCapabilities.includes('config.files.v1') && + node.acceptedCapabilities.includes('config.file-comments.v1')); +} + +function syncTargetCandidates() { + return allNodeItems.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(); + const previousSourceId = voteSitesSourceId; + if (!sources.some(node => node.nodeId === voteSitesSourceId)) { + voteSitesSourceId = sources.find(node => node.nodeId === selectedServerId)?.nodeId || sources[0]?.nodeId || ''; + } + if (previousSourceId && previousSourceId !== voteSitesSourceId) { + approvedVoteSitesPreview = null; + inputGeneration++; + text(voteSitesSyncStatus, 'The sync source became unavailable. Read the replacement source and preview again.'); + } + const targetIds = new Set(targetsAvailable.map(node => node.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).slice(0, MAX_SYNC_TARGETS)); + 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.size >= MAX_SYNC_TARGETS) { + checkbox.checked = false; + text(voteSitesSyncStatus, `A sync operation supports at most ${MAX_SYNC_TARGETS} targets.`); + return; + } + 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); @@ -482,6 +585,7 @@ function renderNodeViews() { renderMetrics(); renderTopology(); renderSelectedServer(); + renderVoteSitesSync(); } function resetServerConfigurationForms(status) { @@ -512,6 +616,7 @@ function updateConfigurationButtons(busy = configurationOperationsInFlight > 0) targets('config.files.v1').length > 0 && !busy; const quickReady = authenticated && primaryCapabilities.includes('config.quick-setup.v1') && 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; @@ -520,6 +625,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) { @@ -530,6 +637,8 @@ function clearApprovals() { approvedPreview = null; approvedFilePreview = null; approvedQuickPreview = null; + approvedVoteSitesPreview = null; + approvedVoteSitesPreview = null; inputGeneration++; updateConfigurationButtons(); } @@ -627,6 +736,7 @@ function discardAuthenticationState(reason) { approvedPreview = null; approvedFilePreview = null; approvedQuickPreview = null; + approvedVoteSitesPreview = null; inputGeneration++; logout.hidden = true; appShell.hidden = true; @@ -638,6 +748,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 = []; allNodeItems = []; @@ -656,6 +769,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.'); @@ -839,10 +953,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.'); } @@ -1181,6 +1300,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, diff --git a/src/main/resources/web/index.html b/src/main/resources/web/index.html index c3f41422..45f8e295 100644 --- a/src/main/resources/web/index.html +++ b/src/main/resources/web/index.html @@ -142,7 +142,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.
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..7882ffdc 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; @@ -445,6 +446,42 @@ class ConfigurationOperationsTest { ManagedConfiguration.QUICK_SETUP, null, List.of(), null, "hidden", "standalone", Map.of())); assertThrows(IllegalArgumentException.class, () -> new ManagedConfiguration( ManagedConfiguration.FILE, null, List.of("other"), "Config.yml", null, null, Map.of())); + new ManagedConfiguration(ManagedConfiguration.QUICK_SETUP, null, List.of(), null, null, + "standalone", Map.of("label", "é".repeat(500))); + } + + @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()).validateProposal()); + assertThrows(IllegalArgumentException.class, () -> new ManagedConfiguration(ManagedConfiguration.QUICK_SETUP, + null, List.of(), null, null, ManagedConfiguration.VOTE_SITES_SYNC, + Map.of("sourceContent", source, "label", "extra")).validateProposal()); + 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")); + 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/domain/InMemoryNodeRegistryTest.java b/src/test/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistryTest.java index 7148be01..a9cc6e2f 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"))); 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 848415e1..e3658aac 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()); @@ -113,6 +115,13 @@ 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("return allNodeItems.filter(node => isBackend(node)")); + assertTrue(script.body().contains("A sync target became unavailable")); + assertTrue(script.body().contains("MAX_SYNC_TARGETS = 100")); + assertTrue(script.body().contains("The sync source became unavailable")); + 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.'"));