From bdaf0e6f69a89f67f0ed97cb638d72692303bdb0 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 14:41:35 -0600 Subject: [PATCH 1/9] Add network transport controls to hosted WebUI --- .../domain/ConfigurationOperations.java | 2 + .../control/domain/InMemoryNodeRegistry.java | 1 + .../protocol/ManagedConfiguration.java | 6 +- src/main/resources/web/app.css | 3 +- src/main/resources/web/app.js | 172 ++++++++++++++++++ src/main/resources/web/index.html | 47 ++++- .../domain/ConfigurationOperationsTest.java | 43 +++++ .../control/http/ControlHttpServerTest.java | 6 + 8 files changed, 274 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java index 158bb4f7..62d695c4 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java @@ -28,6 +28,8 @@ public final class ConfigurationOperations implements AutoCloseable { 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"; + public static final String TRANSPORT_TEST_CAPABILITY = "config.transport-test.v1"; + public static final String PROXY_METHOD_CAPABILITY = "config.proxy-method.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; 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 fc5c146a..0fd4b2dd 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/InMemoryNodeRegistry.java @@ -27,6 +27,7 @@ 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, ConfigurationOperations.VOTE_SITES_SYNC_CAPABILITY, + ConfigurationOperations.TRANSPORT_TEST_CAPABILITY, ConfigurationOperations.PROXY_METHOD_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}"); 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 ed985cba..c0bfbf71 100644 --- a/src/main/java/com/bencodez/votingplugin/control/protocol/ManagedConfiguration.java +++ b/src/main/java/com/bencodez/votingplugin/control/protocol/ManagedConfiguration.java @@ -11,6 +11,8 @@ public record ManagedConfiguration(String domain, Boolean sendVotesToAllServers, 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 String COMMUNICATION_TEST = "communication-test"; + public static final String PROXY_METHOD = "proxy-method"; public static final int MAX_CONTENT = 512 * 1024; public ManagedConfiguration { @@ -60,7 +62,9 @@ public String capability() { case PROXY_ROUTING -> "config.proxy-routing.v1"; case FILE -> "config.files.v1"; case QUICK_SETUP -> VOTE_SITES_SYNC.equals(preset) - ? "config.vote-sites-sync.v1" : "config.quick-setup.v1"; + ? "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"; default -> throw new IllegalStateException("unsupported configuration domain"); }; } diff --git a/src/main/resources/web/app.css b/src/main/resources/web/app.css index deaeac71..620cafb1 100644 --- a/src/main/resources/web/app.css +++ b/src/main/resources/web/app.css @@ -156,7 +156,8 @@ 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, #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; } +.method-buttons { display: grid; grid-template-columns: repeat(auto-fit, minmax(145px, 1fr)); gap: 9px; } +#operation-status, #file-operation-status, #quick-operation-status, #vote-sites-sync-status, #transport-test-status, #proxy-method-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); } diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index 96763646..64d5bad4 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -83,6 +83,15 @@ const voteSitesSyncCapability = document.querySelector('#vote-sites-sync-capabil 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 transportTestProxy = document.querySelector('#transport-test-proxy'); +const transportTestBackend = document.querySelector('#transport-test-backend'); +const transportTestCapability = document.querySelector('#transport-test-capability'); +const runTransportTest = document.querySelector('#run-transport-test'); +const transportTestStatus = document.querySelector('#transport-test-status'); +const proxyMethodProxy = document.querySelector('#proxy-method-proxy'); +const proxyMethodCapability = document.querySelector('#proxy-method-capability'); +const proxyMethodButtons = [...document.querySelectorAll('[data-proxy-method]')]; +const proxyMethodStatus = document.querySelector('#proxy-method-status'); const enrollmentCard = document.querySelector('#enrollment-card'); const enrollmentForm = document.querySelector('#enrollment-form'); const enrollmentSubmit = enrollmentForm.querySelector('button[type="submit"]'); @@ -106,6 +115,9 @@ let approvedVoteSitesPreview = null; let voteSitesSourceId = ''; let voteSitesTargetIds = new Set(); let voteSitesTargetsInitialized = false; +let transportTestProxyId = ''; +let transportTestBackendName = ''; +let proxyMethodProxyId = ''; let nodeCapabilities = new Map(); let nodePlugins = new Map(); let inputGeneration = 0; @@ -168,6 +180,9 @@ function applyAuthenticatedSession(body) { voteSitesSourceId = ''; voteSitesTargetIds.clear(); voteSitesTargetsInitialized = false; + transportTestProxyId = ''; + transportTestBackendName = ''; + proxyMethodProxyId = ''; configurationContent.value = ''; inputGeneration++; logout.hidden = false; @@ -206,6 +221,8 @@ function friendlyCapability(capability) { 'config.files.v1': 'Full configuration', 'config.file-comments.v1': 'Comments preserved', 'config.vote-sites-sync.v1': 'VoteSites sync', + 'config.transport-test.v1': 'Communication test', + 'config.proxy-method.v1': 'Proxy method', 'config.quick-setup.v1': 'Quick Setup', 'config.proxy-routing.v1': 'Proxy routing' })[capability]; @@ -516,6 +533,86 @@ function renderVoteSitesSync() { voteSitesSyncCapability.className = `pill ${readyCount > 0 ? 'online' : 'neutral'}`; } +function transportTestProxies() { + return visibleNodeItems.filter(node => isProxy(node) && node.online && + node.acceptedCapabilities.includes('config.transport-test.v1')); +} + +function renderTransportTest() { + const proxies = transportTestProxies(); + if (!proxies.some(node => node.nodeId === transportTestProxyId)) { + transportTestProxyId = proxies.find(node => node.nodeId === selectedServerId)?.nodeId || proxies[0]?.nodeId || ''; + transportTestBackendName = ''; + } + const proxy = proxies.find(node => node.nodeId === transportTestProxyId); + const backends = Array.isArray(proxy?.backends) ? proxy.backends : []; + if (!backends.some(backend => backend.displayName === transportTestBackendName)) { + transportTestBackendName = backends[0]?.displayName || ''; + } + + const proxyPlaceholder = text(document.createElement('option'), proxies.length ? 'Choose a proxy' : 'No capable proxies'); + proxyPlaceholder.value = ''; + transportTestProxy.replaceChildren(proxyPlaceholder, ...proxies.map(node => { + const option = text(document.createElement('option'), `${node.displayName} · ${node.nodeId}`); + option.value = node.nodeId; + return option; + })); + transportTestProxy.value = transportTestProxyId; + + const backendPlaceholder = text(document.createElement('option'), backends.length ? 'Choose a backend' : 'No backends reported'); + backendPlaceholder.value = ''; + transportTestBackend.replaceChildren(backendPlaceholder, ...backends.map(backend => { + const option = text(document.createElement('option'), `${backend.displayName} · ${backend.backendId}`); + option.value = backend.displayName; + return option; + })); + transportTestBackend.value = transportTestBackendName; + + text(transportTestCapability, proxies.length > 0 + ? `${proxies.length} test-capable ${proxies.length === 1 ? 'proxy' : 'proxies'}` : 'Waiting for a capable proxy'); + transportTestCapability.className = `pill ${proxies.length > 0 ? 'online' : 'neutral'}`; +} + +function proxyMethodCandidates() { + return visibleNodeItems.filter(node => isProxy(node) && node.online && + node.acceptedCapabilities.includes('config.proxy-method.v1')); +} + +function proxyMethodNetwork() { + const proxy = nodeIndex.get(proxyMethodProxyId); + const reported = Array.isArray(proxy?.backends) ? proxy.backends : []; + const backends = reported.map(backend => nodeIndex.get(backend.backendId)).filter(Boolean); + const unavailable = reported.filter(backend => { + const node = nodeIndex.get(backend.backendId); + return !node || !node.online || !node.acceptedCapabilities.includes('config.proxy-method.v1'); + }); + return {proxy, reported, backends, unavailable, + nodeIds: proxy ? [proxy.nodeId, ...backends.map(node => node.nodeId)] : []}; +} + +function renderProxyMethod() { + const proxies = proxyMethodCandidates(); + if (!proxies.some(node => node.nodeId === proxyMethodProxyId)) { + proxyMethodProxyId = proxies.find(node => node.nodeId === selectedServerId)?.nodeId || proxies[0]?.nodeId || ''; + } + const placeholder = text(document.createElement('option'), proxies.length ? 'Choose a proxy' : 'No capable proxies'); + placeholder.value = ''; + proxyMethodProxy.replaceChildren(placeholder, ...proxies.map(node => { + const option = text(document.createElement('option'), `${node.displayName} · ${node.nodeId}`); + option.value = node.nodeId; + return option; + })); + proxyMethodProxy.value = proxyMethodProxyId; + const network = proxyMethodNetwork(); + const ready = Boolean(network.proxy) && network.reported.length > 0 && network.unavailable.length === 0; + const description = !network.proxy ? 'Waiting for a capable proxy' + : network.reported.length === 0 ? 'No backends reported' + : network.unavailable.length > 0 ? `${network.unavailable.length} backends unavailable` + : `${network.nodeIds.length} nodes ready`; + text(proxyMethodCapability, description); + proxyMethodCapability.className = `pill ${ready ? 'online' : 'neutral'}`; +} + function renderNodeViews() { nodes.replaceChildren(); nodes.classList.toggle('empty', visibleNodeItems.length === 0); @@ -528,6 +625,8 @@ function renderNodeViews() { renderTopology(); renderSelectedServer(); renderVoteSitesSync(); + renderTransportTest(); + renderProxyMethod(); } function selectPrimaryServer(nodeId) { @@ -556,6 +655,11 @@ function updateConfigurationButtons(busy = configurationOperationsInFlight > 0) applyQuickSetup.disabled = !quickReady || !approvedQuickPreview; previewVoteSitesSync.disabled = !voteSitesReady; applyVoteSitesSync.disabled = !voteSitesReady || !approvedVoteSitesPreview; + runTransportTest.disabled = !authenticated || !transportTestProxyId || !transportTestBackendName || busy; + const methodNetwork = proxyMethodNetwork(); + const methodReady = authenticated && methodNetwork.reported.length > 0 && + methodNetwork.unavailable.length === 0 && !busy; + proxyMethodButtons.forEach(button => { button.disabled = !methodReady; }); } function targets(capability) { @@ -676,6 +780,9 @@ function discardAuthenticationState(reason) { voteSitesSourceId = ''; voteSitesTargetIds.clear(); voteSitesTargetsInitialized = false; + transportTestProxyId = ''; + transportTestBackendName = ''; + proxyMethodProxyId = ''; selectedServerId = ''; visibleNodeItems = []; nodeIndex.clear(); @@ -691,6 +798,8 @@ function discardAuthenticationState(reason) { text(fileOperationStatus, ''); text(quickOperationStatus, ''); text(voteSitesSyncStatus, ''); + text(transportTestStatus, ''); + text(proxyMethodStatus, ''); nodes.replaceChildren(); nodes.classList.add('empty'); text(nodes, 'Authenticate to view the network.'); @@ -1211,6 +1320,69 @@ applyVoteSitesSync.addEventListener('click', async () => { } catch (error) { text(voteSitesSyncStatus, error.message); } }); +transportTestProxy.addEventListener('change', () => { + transportTestProxyId = transportTestProxy.value; + transportTestBackendName = ''; + renderTransportTest(); + text(transportTestStatus, 'Choose a backend, then run a live non-vote communication test.'); + updateConfigurationButtons(); +}); + +transportTestBackend.addEventListener('change', () => { + transportTestBackendName = transportTestBackend.value; + text(transportTestStatus, 'Ready to test the active VotingPlugin transport.'); + updateConfigurationButtons(); +}); + +runTransportTest.addEventListener('click', async () => { + const proxyId = transportTestProxyId; + const server = transportTestBackendName; + try { + const operation = await startConfigurationOperation('/api/v1/configuration/read', { + nodeIds: [proxyId], + configuration: {domain: 'quick-setup', preset: 'communication-test', options: {server}} + }, transportTestStatus); + if (proxyId !== transportTestProxyId || server !== transportTestBackendName) { + text(transportTestStatus, 'The proxy or backend changed while testing. Run the test again.'); + return; + } + text(transportTestStatus, operationSummary(operation)); + } catch (error) { text(transportTestStatus, error.message); } +}); + +proxyMethodProxy.addEventListener('change', () => { + proxyMethodProxyId = proxyMethodProxy.value; + renderProxyMethod(); + const network = proxyMethodNetwork(); + 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.'); + updateConfigurationButtons(); +}); + +proxyMethodButtons.forEach(button => button.addEventListener('click', async () => { + const method = button.dataset.proxyMethod; + const network = proxyMethodNetwork(); + if (!network.proxy || network.reported.length === 0 || network.unavailable.length > 0) return; + try { + const preview = await startConfigurationOperation('/api/v1/configuration/preview', { + nodeIds: network.nodeIds, + configuration: {domain: 'quick-setup', preset: 'proxy-method', options: {method}} + }, proxyMethodStatus); + if (preview.state !== 'SUCCEEDED' || !preview.approvalToken) return; + if (proxyMethodProxyId !== network.proxy.nodeId) { + text(proxyMethodStatus, 'The selected proxy changed while preflighting. Choose the method again.'); + return; + } + if (!window.confirm(`Switch ${network.nodeIds.length} VotingPlugin nodes to ${method}? ` + + 'The proxy runtime will restart after Control records the result.')) return; + const applied = await startConfigurationOperation('/api/v1/configuration/apply', { + previewOperationId: preview.operationId, approvalToken: preview.approvalToken + }, proxyMethodStatus); + text(proxyMethodStatus, `${operationSummary(applied)}\nReconnect the proxy if needed, then run the communication test.`); + } catch (error) { text(proxyMethodStatus, 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 875cb5b0..c4f9f95a 100644 --- a/src/main/resources/web/index.html +++ b/src/main/resources/web/index.html @@ -135,12 +135,51 @@

Servers

Global VotingPlugin settings

Network

-

Routing currently uses the existing proxy capability. Method checks, transport tests, and VoteSites sync will appear here as nodes advertise those capabilities.

+

Manage network-wide VotingPlugin behavior and verify the live proxy-to-backend transport without sending a vote.

-
-
NextProxy methodOne-click selection with required-setting checks
-
NextCommunication testNon-vote connectivity checks without rewards
+
+
+
+

Network transport

+

Proxy method

+

Choose a method to preflight its required settings on the proxy and every reported backend, then approve the coordinated switch.

+
+ Waiting for a complete network +
+ +
+ + + + + +
+
Choose a proxy. All of its reported backends must be online and method-capable.
+
+
+
+
+

Live diagnostic

+

Test proxy communication

+

Send a correlated status request over the active VotingPlugin transport. No vote, totals, commands, or rewards are processed.

+
+ Waiting for a capable proxy +
+
+ + +
+
+ +
+
Choose a proxy and one of its configured backends.
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..4c49e567 100644 --- a/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/domain/ConfigurationOperationsTest.java @@ -472,6 +472,49 @@ class ConfigurationOperationsTest { assertEquals(source, operations.claim("backend-a", session).configuration().options().get("sourceContent")); } + @Test void communicationTestUsesItsOwnReadOnlyCapability() throws Exception { + ManagedConfiguration diagnostic = new ManagedConfiguration(ManagedConfiguration.QUICK_SETUP, null, List.of(), + null, null, ManagedConfiguration.COMMUNICATION_TEST, Map.of("server", "survival")); + assertEquals(ConfigurationOperations.TRANSPORT_TEST_CAPABILITY, diagnostic.capability()); + + 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("proxy-a", session, "Proxy A", "VELOCITY", "test", 1, + Set.of(ConfigurationOperations.TRANSPORT_TEST_CAPABILITY), Set.of())); + ConfigurationOperations operations = new ConfigurationOperations(registry, + new ConfigurationAuditLog(directory, clock), clock); + + ConfigurationOperations.OperationView read = operations.createRead(List.of("proxy-a"), diagnostic); + ConfigurationTask task = operations.claim("proxy-a", session); + assertEquals("READ", task.type()); + assertEquals("communication-test", task.configuration().preset()); + assertEquals("survival", task.configuration().options().get("server")); + assertEquals(read.operationId(), task.operationId()); + } + + @Test void proxyMethodUsesItsOwnCoordinatedCapability() throws Exception { + ManagedConfiguration method = new ManagedConfiguration(ManagedConfiguration.QUICK_SETUP, null, List.of(), + null, null, ManagedConfiguration.PROXY_METHOD, Map.of("method", "REDIS")); + assertEquals(ConfigurationOperations.PROXY_METHOD_CAPABILITY, method.capability()); + + Clock clock = Clock.fixed(Instant.parse("2026-08-25T00:00:00Z"), ZoneOffset.UTC); + InMemoryNodeRegistry registry = new InMemoryNodeRegistry(clock, Duration.ofMinutes(2)); + UUID proxySession = UUID.randomUUID(); + UUID backendSession = UUID.randomUUID(); + registry.register(new NodeRegistration("proxy-a", proxySession, "Proxy A", "VELOCITY", "test", 1, + Set.of(ConfigurationOperations.PROXY_METHOD_CAPABILITY), Set.of())); + registry.register(new NodeRegistration("lobby", backendSession, "Lobby", "BUKKIT", "test", 1, + Set.of(ConfigurationOperations.PROXY_METHOD_CAPABILITY), Set.of())); + ConfigurationOperations operations = new ConfigurationOperations(registry, + new ConfigurationAuditLog(directory, clock), clock); + + ConfigurationOperations.OperationView preview = operations.createPreview(List.of("proxy-a", "lobby"), method); + assertEquals("proxy-method", operations.claim("proxy-a", proxySession).configuration().preset()); + assertEquals("proxy-method", operations.claim("lobby", backendSession).configuration().preset()); + assertEquals(2, preview.nodeStates().size()); + } + @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)); 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..06233a15 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -106,8 +106,14 @@ class ControlHttpServerTest { 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("'config.transport-test.v1': 'Communication test'")); + assertTrue(script.body().contains("'config.proxy-method.v1': 'Proxy method'")); assertTrue(script.body().contains("preset: 'sync-vote-sites'")); assertTrue(script.body().contains("sourceContent: source")); + assertTrue(script.body().contains("preset: 'communication-test'")); + assertTrue(script.body().contains("runTransportTest.addEventListener")); + assertTrue(script.body().contains("preset: 'proxy-method'")); + assertTrue(script.body().contains("proxyMethodButtons.forEach")); assertTrue(script.body().contains("handleEditorKeydown")); assertFalse(script.body().contains("'No backends reported.'")); assertFalse(script.body().contains("'No Bukkit plugin inventory reported.'")); From ba0f3c88e51f9580be459fa977feae7e48a7aa28 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 15:16:41 -0600 Subject: [PATCH 2/9] Address Codex transport control review findings --- src/main/resources/web/app.js | 109 +++++++++++------- .../control/http/ControlHttpServerTest.java | 7 ++ 2 files changed, 77 insertions(+), 39 deletions(-) diff --git a/src/main/resources/web/app.js b/src/main/resources/web/app.js index 64d5bad4..fd213df0 100644 --- a/src/main/resources/web/app.js +++ b/src/main/resources/web/app.js @@ -107,7 +107,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; @@ -116,8 +118,9 @@ let voteSitesSourceId = ''; let voteSitesTargetIds = new Set(); let voteSitesTargetsInitialized = false; let transportTestProxyId = ''; -let transportTestBackendName = ''; +let transportTestBackendId = ''; let proxyMethodProxyId = ''; +let backendTopologyTruncated = false; let nodeCapabilities = new Map(); let nodePlugins = new Map(); let inputGeneration = 0; @@ -181,7 +184,7 @@ function applyAuthenticatedSession(body) { voteSitesTargetIds.clear(); voteSitesTargetsInitialized = false; transportTestProxyId = ''; - transportTestBackendName = ''; + transportTestBackendId = ''; proxyMethodProxyId = ''; configurationContent.value = ''; inputGeneration++; @@ -233,7 +236,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)); } @@ -243,8 +246,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 @@ -355,7 +358,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); }); @@ -411,8 +414,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 @@ -425,7 +428,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) { @@ -454,23 +457,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')); } @@ -486,8 +489,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)); @@ -534,7 +543,7 @@ function renderVoteSitesSync() { } function transportTestProxies() { - return visibleNodeItems.filter(node => isProxy(node) && node.online && + return allNodeItems.filter(node => isProxy(node) && node.online && node.acceptedCapabilities.includes('config.transport-test.v1')); } @@ -542,12 +551,12 @@ function renderTransportTest() { const proxies = transportTestProxies(); if (!proxies.some(node => node.nodeId === transportTestProxyId)) { transportTestProxyId = proxies.find(node => node.nodeId === selectedServerId)?.nodeId || proxies[0]?.nodeId || ''; - transportTestBackendName = ''; + transportTestBackendId = ''; } const proxy = proxies.find(node => node.nodeId === transportTestProxyId); const backends = Array.isArray(proxy?.backends) ? proxy.backends : []; - if (!backends.some(backend => backend.displayName === transportTestBackendName)) { - transportTestBackendName = backends[0]?.displayName || ''; + if (!backends.some(backend => backend.backendId === transportTestBackendId)) { + transportTestBackendId = backends[0]?.backendId || ''; } const proxyPlaceholder = text(document.createElement('option'), proxies.length ? 'Choose a proxy' : 'No capable proxies'); @@ -563,10 +572,10 @@ function renderTransportTest() { backendPlaceholder.value = ''; transportTestBackend.replaceChildren(backendPlaceholder, ...backends.map(backend => { const option = text(document.createElement('option'), `${backend.displayName} · ${backend.backendId}`); - option.value = backend.displayName; + option.value = backend.backendId; return option; })); - transportTestBackend.value = transportTestBackendName; + transportTestBackend.value = transportTestBackendId; text(transportTestCapability, proxies.length > 0 ? `${proxies.length} test-capable ${proxies.length === 1 ? 'proxy' : 'proxies'}` : 'Waiting for a capable proxy'); @@ -574,7 +583,7 @@ function renderTransportTest() { } function proxyMethodCandidates() { - return visibleNodeItems.filter(node => isProxy(node) && node.online && + return allNodeItems.filter(node => isProxy(node) && node.online && node.acceptedCapabilities.includes('config.proxy-method.v1')); } @@ -586,7 +595,7 @@ function proxyMethodNetwork() { const node = nodeIndex.get(backend.backendId); return !node || !node.online || !node.acceptedCapabilities.includes('config.proxy-method.v1'); }); - return {proxy, reported, backends, unavailable, + return {proxy, reported, backends, unavailable, topologyComplete: !backendTopologyTruncated, nodeIds: proxy ? [proxy.nodeId, ...backends.map(node => node.nodeId)] : []}; } @@ -604,8 +613,9 @@ function renderProxyMethod() { })); proxyMethodProxy.value = proxyMethodProxyId; const network = proxyMethodNetwork(); - const ready = Boolean(network.proxy) && network.reported.length > 0 && network.unavailable.length === 0; + const ready = Boolean(network.proxy) && network.topologyComplete && network.reported.length > 0 && network.unavailable.length === 0; const description = !network.proxy ? 'Waiting for a capable proxy' + : !network.topologyComplete ? 'Backend topology is truncated; switching disabled' : network.reported.length === 0 ? 'No backends reported' : network.unavailable.length > 0 ? `${network.unavailable.length} backends unavailable` : `${network.nodeIds.length} nodes ready`; @@ -655,9 +665,9 @@ function updateConfigurationButtons(busy = configurationOperationsInFlight > 0) applyQuickSetup.disabled = !quickReady || !approvedQuickPreview; previewVoteSitesSync.disabled = !voteSitesReady; applyVoteSitesSync.disabled = !voteSitesReady || !approvedVoteSitesPreview; - runTransportTest.disabled = !authenticated || !transportTestProxyId || !transportTestBackendName || busy; + runTransportTest.disabled = !authenticated || !transportTestProxyId || !transportTestBackendId || busy; const methodNetwork = proxyMethodNetwork(); - const methodReady = authenticated && methodNetwork.reported.length > 0 && + const methodReady = authenticated && Boolean(methodNetwork.proxy) && methodNetwork.topologyComplete && methodNetwork.reported.length > 0 && methodNetwork.unavailable.length === 0 && !busy; proxyMethodButtons.forEach(button => { button.disabled = !methodReady; }); } @@ -781,10 +791,13 @@ function discardAuthenticationState(reason) { voteSitesTargetIds.clear(); voteSitesTargetsInitialized = false; transportTestProxyId = ''; - transportTestBackendName = ''; + transportTestBackendId = ''; + backendTopologyTruncated = false; proxyMethodProxyId = ''; selectedServerId = ''; visibleNodeItems = []; + allNodeItems = []; + enrollmentIds.clear(); nodeIndex.clear(); nodeCapabilities.clear(); nodePlugins.clear(); @@ -863,6 +876,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.')); @@ -905,6 +920,17 @@ async function loadEnrollments() { } } +async function loadAllNodes() { + const items = []; + let truncated = false; + for (let offset = 0; ; offset += PAGE_SIZE) { + const page = await authorized(`/api/v1/nodes?offset=${offset}&limit=${PAGE_SIZE}`); + items.push(...page.items); + truncated ||= Boolean(page.backendItemsTruncated); + if (page.items.length < PAGE_SIZE) return {items, truncated}; + } +} + async function loadNodes() { if (!authenticated) return; refresh.disabled = true; @@ -912,13 +938,18 @@ 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.items; + backendTopologyTruncated = registry.truncated; + nodeIndex = new Map(registry.items.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.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 selectedCapabilitiesChanged = [...selectedNodes].some(node => ['config.proxy-routing.v1', 'config.files.v1', 'config.quick-setup.v1'].some(capability => @@ -949,7 +980,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.items.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) { @@ -1322,27 +1353,27 @@ applyVoteSitesSync.addEventListener('click', async () => { transportTestProxy.addEventListener('change', () => { transportTestProxyId = transportTestProxy.value; - transportTestBackendName = ''; + transportTestBackendId = ''; renderTransportTest(); text(transportTestStatus, 'Choose a backend, then run a live non-vote communication test.'); updateConfigurationButtons(); }); transportTestBackend.addEventListener('change', () => { - transportTestBackendName = transportTestBackend.value; + transportTestBackendId = transportTestBackend.value; text(transportTestStatus, 'Ready to test the active VotingPlugin transport.'); updateConfigurationButtons(); }); runTransportTest.addEventListener('click', async () => { const proxyId = transportTestProxyId; - const server = transportTestBackendName; + const server = transportTestBackendId; try { const operation = await startConfigurationOperation('/api/v1/configuration/read', { nodeIds: [proxyId], configuration: {domain: 'quick-setup', preset: 'communication-test', options: {server}} }, transportTestStatus); - if (proxyId !== transportTestProxyId || server !== transportTestBackendName) { + if (proxyId !== transportTestProxyId || server !== transportTestBackendId) { text(transportTestStatus, 'The proxy or backend changed while testing. Run the test again.'); return; } @@ -1363,7 +1394,7 @@ proxyMethodProxy.addEventListener('change', () => { proxyMethodButtons.forEach(button => button.addEventListener('click', async () => { const method = button.dataset.proxyMethod; const network = proxyMethodNetwork(); - if (!network.proxy || network.reported.length === 0 || network.unavailable.length > 0) return; + if (!network.proxy || !network.topologyComplete || network.reported.length === 0 || network.unavailable.length > 0) return; try { const preview = await startConfigurationOperation('/api/v1/configuration/preview', { nodeIds: network.nodeIds, 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 06233a15..b7dd0a44 100644 --- a/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java +++ b/src/test/java/com/bencodez/votingplugin/control/http/ControlHttpServerTest.java @@ -71,11 +71,16 @@ 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")); assertTrue(script.body().contains("if (loginInFlight) return")); assertTrue(script.body().contains("backendItemsTruncated")); + assertTrue(script.body().contains("topologyComplete: !backendTopologyTruncated")); + assertTrue(script.body().contains("option.value = backend.backendId")); + assertTrue(script.body().contains("return allNodeItems.filter(node => isProxy(node)")); assertTrue(web.body().contains("Easy vote reward")); assertTrue(web.body().contains("First-run setup")); assertTrue(web.body().contains("Node enrollment")); @@ -109,6 +114,8 @@ class ControlHttpServerTest { assertTrue(script.body().contains("'config.transport-test.v1': 'Communication test'")); assertTrue(script.body().contains("'config.proxy-method.v1': 'Proxy method'")); 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("preset: 'communication-test'")); assertTrue(script.body().contains("runTransportTest.addEventListener")); From 8a7471a8b8203980d061154fff9d089877f1eed3 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 29 Aug 2026 15:17:32 -0600 Subject: [PATCH 3/9] Align transport stack with prerequisite fixes --- .../control/domain/ConfigurationOperations.java | 4 +++- src/main/resources/web/index.html | 3 ++- .../control/domain/ConfigurationOperationsTest.java | 7 ++++++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java index 62d695c4..8dbaf8de 100644 --- a/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java +++ b/src/main/java/com/bencodez/votingplugin/control/domain/ConfigurationOperations.java @@ -234,7 +234,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/index.html b/src/main/resources/web/index.html index c4f9f95a..cca2f4d9 100644 --- a/src/main/resources/web/index.html +++ b/src/main/resources/web/index.html @@ -63,9 +63,10 @@

Sign in to your network

-

+

+